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:
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.AccountManager
import com.rosetta.messenger.data.DecryptedAccount import com.rosetta.messenger.data.DecryptedAccount
import com.rosetta.messenger.data.PreferencesManager import com.rosetta.messenger.data.PreferencesManager
import com.rosetta.messenger.ui.auth.AccountInfo
import com.rosetta.messenger.ui.auth.AuthFlow import com.rosetta.messenger.ui.auth.AuthFlow
import com.rosetta.messenger.ui.chats.Chat
import com.rosetta.messenger.ui.chats.ChatsListScreen
import com.rosetta.messenger.ui.onboarding.OnboardingScreen import com.rosetta.messenger.ui.onboarding.OnboardingScreen
import com.rosetta.messenger.ui.splash.SplashScreen import com.rosetta.messenger.ui.splash.SplashScreen
import com.rosetta.messenger.ui.theme.RosettaAndroidTheme import com.rosetta.messenger.ui.theme.RosettaAndroidTheme
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.*
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private lateinit var preferencesManager: PreferencesManager private lateinit var preferencesManager: PreferencesManager
@@ -49,11 +53,31 @@ class MainActivity : ComponentActivity() {
var showOnboarding by remember { mutableStateOf(true) } var showOnboarding by remember { mutableStateOf(true) }
var hasExistingAccount by remember { mutableStateOf<Boolean?>(null) } var hasExistingAccount by remember { mutableStateOf<Boolean?>(null) }
var currentAccount by remember { mutableStateOf<DecryptedAccount?>(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) { LaunchedEffect(Unit) {
val accounts = accountManager.getAllAccounts() val accounts = accountManager.getAllAccounts()
hasExistingAccount = accounts.isNotEmpty() 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 // Wait for initial load
@@ -112,15 +136,46 @@ class MainActivity : ComponentActivity() {
AuthFlow( AuthFlow(
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
hasExistingAccount = screen == "auth_unlock", hasExistingAccount = screen == "auth_unlock",
accounts = accountInfoList,
onAuthComplete = { account -> onAuthComplete = { account ->
currentAccount = account currentAccount = account
hasExistingAccount = true 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" -> { "main" -> {
MainScreen( MainScreen(
account = currentAccount, account = currentAccount,
isDarkTheme = isDarkTheme,
onLogout = { onLogout = {
scope.launch { scope.launch {
accountManager.logout() accountManager.logout()
@@ -140,17 +195,54 @@ class MainActivity : ComponentActivity() {
@Composable @Composable
fun MainScreen( fun MainScreen(
account: DecryptedAccount? = null, account: DecryptedAccount? = null,
isDarkTheme: Boolean = true,
onLogout: () -> Unit = {} onLogout: () -> Unit = {}
) { ) {
Box( // Demo chats for now
modifier = Modifier.fillMaxSize(), val demoChats = remember {
contentAlignment = Alignment.Center listOf(
) { Chat(
Text( id = "1",
text = "Welcome to Rosetta! 🚀\n\nYou're logged in!", name = "Alice Johnson",
fontSize = 24.sp, lastMessage = "Hey! How are you doing?",
fontWeight = FontWeight.Bold, lastMessageTime = Date(),
color = MaterialTheme.colorScheme.onBackground 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 import com.rosetta.messenger.data.DecryptedAccount
enum class AuthScreen { enum class AuthScreen {
SELECT_ACCOUNT,
WELCOME, WELCOME,
SEED_PHRASE, SEED_PHRASE,
CONFIRM_SEED, CONFIRM_SEED,
@@ -18,12 +19,22 @@ enum class AuthScreen {
fun AuthFlow( fun AuthFlow(
isDarkTheme: Boolean, isDarkTheme: Boolean,
hasExistingAccount: Boolean, hasExistingAccount: Boolean,
onAuthComplete: (DecryptedAccount?) -> Unit accounts: List<AccountInfo> = emptyList(),
onAuthComplete: (DecryptedAccount?) -> Unit,
onLogout: () -> Unit = {}
) { ) {
var currentScreen by remember { 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 seedPhrase by remember { mutableStateOf<List<String>>(emptyList()) }
var selectedAccountId by remember { mutableStateOf<String?>(accounts.firstOrNull()?.id) }
var showCreateModal by remember { mutableStateOf(false) }
AnimatedContent( AnimatedContent(
targetState = currentScreen, targetState = currentScreen,
@@ -34,6 +45,29 @@ fun AuthFlow(
label = "authScreenTransition" label = "authScreenTransition"
) { screen -> ) { screen ->
when (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 -> { AuthScreen.WELCOME -> {
WelcomeScreen( WelcomeScreen(
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
@@ -45,7 +79,11 @@ fun AuthFlow(
AuthScreen.SEED_PHRASE -> { AuthScreen.SEED_PHRASE -> {
SeedPhraseScreen( SeedPhraseScreen(
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
onBack = { currentScreen = AuthScreen.WELCOME }, onBack = {
currentScreen = if (hasExistingAccount && accounts.size > 1)
AuthScreen.SELECT_ACCOUNT
else AuthScreen.WELCOME
},
onConfirm = { words -> onConfirm = { words ->
seedPhrase = words seedPhrase = words
currentScreen = AuthScreen.CONFIRM_SEED currentScreen = AuthScreen.CONFIRM_SEED
@@ -74,7 +112,11 @@ fun AuthFlow(
AuthScreen.IMPORT_SEED -> { AuthScreen.IMPORT_SEED -> {
ImportSeedPhraseScreen( ImportSeedPhraseScreen(
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
onBack = { currentScreen = AuthScreen.WELCOME }, onBack = {
currentScreen = if (hasExistingAccount && accounts.size > 1)
AuthScreen.SELECT_ACCOUNT
else AuthScreen.WELCOME
},
onSeedPhraseImported = { words -> onSeedPhraseImported = { words ->
seedPhrase = words seedPhrase = words
currentScreen = AuthScreen.SET_PASSWORD currentScreen = AuthScreen.SET_PASSWORD
@@ -85,7 +127,13 @@ fun AuthFlow(
AuthScreen.UNLOCK -> { AuthScreen.UNLOCK -> {
UnlockScreen( UnlockScreen(
isDarkTheme = isDarkTheme, 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.animation.core.*
import androidx.compose.foundation.* import androidx.compose.foundation.*
import androidx.compose.foundation.layout.* 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.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.filled.*
import androidx.compose.material3.* import androidx.compose.material3.*
@@ -17,19 +13,33 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color 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.FontFamily
import androidx.compose.ui.text.font.FontWeight 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.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.rosetta.messenger.crypto.CryptoManager
import com.rosetta.messenger.ui.onboarding.PrimaryBlue 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 @Composable
fun ConfirmSeedPhraseScreen( fun ConfirmSeedPhraseScreen(
seedPhrase: List<String>, seedPhrase: List<String>,
@@ -37,18 +47,16 @@ fun ConfirmSeedPhraseScreen(
onBack: () -> Unit, onBack: () -> Unit,
onConfirmed: () -> Unit onConfirmed: () -> Unit
) { ) {
val themeAnimSpec = tween<Color>(durationMillis = 500, easing = CubicBezierEasing(0.4f, 0f, 0.2f, 1f)) val backgroundColor = if (isDarkTheme) Color(0xFF1E1E1E) else Color(0xFFFFFFFF)
val backgroundColor by animateColorAsState(if (isDarkTheme) AuthBackground else AuthBackgroundLight, animationSpec = themeAnimSpec) val textColor = if (isDarkTheme) Color.White else Color.Black
val textColor by animateColorAsState(if (isDarkTheme) Color.White else Color.Black, animationSpec = themeAnimSpec) val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
val secondaryTextColor by animateColorAsState(if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666), animationSpec = themeAnimSpec)
val cardColor by animateColorAsState(if (isDarkTheme) AuthSurface else AuthSurfaceLight, animationSpec = themeAnimSpec)
// Select 4 random words to confirm // Select 4 words at fixed positions to confirm (2, 5, 9, 12)
val wordsToConfirm = remember { val wordsToConfirm = remember {
listOf(1, 4, 8, 11).map { index -> index to seedPhrase[index] } 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 showError by remember { mutableStateOf(false) }
var visible by remember { mutableStateOf(false) } var visible by remember { mutableStateOf(false) }
@@ -56,43 +64,33 @@ fun ConfirmSeedPhraseScreen(
visible = true visible = true
} }
val allCorrect = wordsToConfirm.mapIndexed { i, (_, word) -> // Check if the 4 confirmation words are correct
userInputs[i].trim().lowercase() == word.lowercase() val allCorrect = wordsToConfirm.all { (index, word) ->
}.all { it } userInputs[index].trim().lowercase() == word.lowercase()
}
// Check if all 4 words have input
val allFilled = wordsToConfirm.all { (index, _) ->
userInputs[index].isNotBlank()
}
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(backgroundColor) .background(backgroundColor)
.statusBarsPadding()
) { ) {
Column( Column(modifier = Modifier.fillMaxSize()) {
modifier = Modifier // Top bar
.fillMaxSize()
.statusBarsPadding()
) {
// Top Bar
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 8.dp), .padding(4.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
IconButton(onClick = onBack) { IconButton(onClick = onBack) {
Icon( Icon(Icons.Default.ArrowBack, "Back", tint = textColor)
imageVector = Icons.Default.ArrowBack,
contentDescription = "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( Column(
@@ -104,82 +102,127 @@ fun ConfirmSeedPhraseScreen(
) { ) {
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
// Info Card
AnimatedVisibility( AnimatedVisibility(
visible = visible, visible = visible,
enter = fadeIn(tween(500)) + slideInVertically( enter = fadeIn(tween(500)) + slideInVertically(
initialOffsetY = { -30 }, initialOffsetY = { -20 },
animationSpec = tween(500) 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(
text = "Enter the following words from your seed phrase to confirm you've backed it up correctly.", text = "Confirm Backup",
fontSize = 14.sp, fontSize = 28.sp,
color = secondaryTextColor, fontWeight = FontWeight.Bold,
lineHeight = 18.sp 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)) Spacer(modifier = Modifier.height(32.dp))
// Word inputs // Two column layout like SeedPhraseScreen
wordsToConfirm.forEachIndexed { index, (wordIndex, _) -> AnimatedVisibility(
val isCorrect = userInputs[index].trim().lowercase() == visible = visible,
wordsToConfirm[index].second.lowercase() enter = fadeIn(tween(500, delayMillis = 200))
val hasInput = userInputs[index].isNotBlank() ) {
Row(
AnimatedVisibility( modifier = Modifier.fillMaxWidth(),
visible = visible, horizontalArrangement = Arrangement.spacedBy(12.dp)
enter = fadeIn(tween(500, delayMillis = 100 + (index * 100))) + slideInVertically(
initialOffsetY = { 50 },
animationSpec = tween(500, delayMillis = 100 + (index * 100))
)
) { ) {
WordInputField( // Left column (words 1-6)
wordNumber = wordIndex + 1, Column(
value = userInputs[index], modifier = Modifier.weight(1f),
onValueChange = { verticalArrangement = Arrangement.spacedBy(12.dp)
userInputs = userInputs.toMutableList().apply { ) {
this[index] = it for (i in 0..5) {
} val needsInput = wordsToConfirm.any { it.first == i }
showError = false val correctWord = seedPhrase[i]
},
isCorrect = if (hasInput) isCorrect else null, AnimatedConfirmWordItem(
isDarkTheme = isDarkTheme 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 // Error message
AnimatedVisibility( AnimatedVisibility(
visible = showError, visible = showError,
enter = fadeIn() + slideInVertically { -10 }, enter = fadeIn(tween(300)) + scaleIn(initialScale = 0.9f),
exit = fadeOut() exit = fadeOut(tween(200))
) { ) {
Text( Column {
text = "Some words don't match. Please check and try again.", Spacer(modifier = Modifier.height(16.dp))
fontSize = 14.sp, Text(
color = Color(0xFFE53935), text = "Some words don't match. Please check and try again.",
textAlign = TextAlign.Center fontSize = 14.sp,
) color = Color(0xFFE53935),
textAlign = TextAlign.Center
)
}
} }
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))
@@ -187,90 +230,208 @@ fun ConfirmSeedPhraseScreen(
// Continue Button // Continue Button
AnimatedVisibility( AnimatedVisibility(
visible = visible, visible = visible,
enter = fadeIn(tween(500, delayMillis = 500)) + slideInVertically( enter = fadeIn(tween(500, delayMillis = 600)) + slideInVertically(
initialOffsetY = { 50 }, initialOffsetY = { 50 },
animationSpec = tween(500, delayMillis = 500) animationSpec = tween(500, delayMillis = 600)
) )
) { ) {
Button( Button(
onClick = { onClick = {
if (allCorrect) { if (allCorrect) {
onConfirmed() onConfirmed()
} else { } else {
showError = true showError = true
} }
}, },
modifier = Modifier enabled = allFilled,
.fillMaxWidth() modifier = Modifier
.height(56.dp), .fillMaxWidth()
colors = ButtonDefaults.buttonColors( .height(50.dp),
containerColor = if (allCorrect) PrimaryBlue else PrimaryBlue.copy(alpha = 0.5f) colors = ButtonDefaults.buttonColors(
), containerColor = PrimaryBlue,
shape = RoundedCornerShape(12.dp) contentColor = Color.White,
) { disabledContainerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8),
Text( disabledContentColor = if (isDarkTheme) Color(0xFF666666) else Color(0xFF999999)
text = "Confirm", ),
fontSize = 16.sp, shape = RoundedCornerShape(12.dp)
fontWeight = FontWeight.SemiBold ) {
) 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 @Composable
private fun WordInputField( private fun AnimatedConfirmWordItem(
wordNumber: Int, 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, value: String,
onValueChange: (String) -> Unit, onValueChange: (String) -> Unit,
isCorrect: Boolean?, isCorrect: Boolean?,
isDarkTheme: Boolean isDarkTheme: Boolean,
modifier: Modifier = Modifier
) { ) {
val textColor = if (isDarkTheme) Color.White else Color.Black val numberColor = if (isDarkTheme) Color(0xFF888888) else Color(0xFF999999)
val labelColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) 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) { val borderColor = when (isCorrect) {
true -> Color(0xFF4CAF50) true -> Color(0xFF4CAF50) // Green
false -> Color(0xFFE53935) false -> Color(0xFFE53935) // Red
null -> if (isDarkTheme) Color(0xFF4A4A4A) else Color(0xFFD0D0D0) null -> if (isFocused) PrimaryBlue else Color.Transparent
} }
val trailingIcon: @Composable (() -> Unit)? = when (isCorrect) { Box(
true -> { modifier = modifier
{ Icon(Icons.Default.Check, null, tint = Color(0xFF4CAF50)) } .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
)
)
} }

View File

@@ -16,6 +16,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@@ -35,12 +36,10 @@ fun ImportSeedPhraseScreen(
val backgroundColor = if (isDarkTheme) Color(0xFF1E1E1E) else Color(0xFFFFFFFF) val backgroundColor = if (isDarkTheme) Color(0xFF1E1E1E) else Color(0xFFFFFFFF)
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
val cardBackground = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFF5F5F5)
val clipboardManager = LocalClipboardManager.current
var words by remember { mutableStateOf(List(12) { "" }) } var words by remember { mutableStateOf(List(12) { "" }) }
var error by remember { mutableStateOf<String?>(null) } var error by remember { mutableStateOf<String?>(null) }
var pastedText by remember { mutableStateOf("") }
var showPasteDialog by remember { mutableStateOf(false) }
var visible by remember { mutableStateOf(false) } var visible by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
@@ -49,18 +48,6 @@ fun ImportSeedPhraseScreen(
val allWordsFilled = words.all { it.isNotBlank() } 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( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -68,7 +55,7 @@ fun ImportSeedPhraseScreen(
.statusBarsPadding() .statusBarsPadding()
) { ) {
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
// Simple top bar // Top bar
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -80,17 +67,13 @@ fun ImportSeedPhraseScreen(
} }
} }
AnimatedVisibility( Column(
visible = visible, modifier = Modifier
enter = fadeIn(tween(500, easing = FastOutSlowInEasing)) .fillMaxSize()
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Column( Spacer(modifier = Modifier.height(16.dp))
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(16.dp))
AnimatedVisibility( AnimatedVisibility(
visible = visible, visible = visible,
@@ -111,98 +94,140 @@ fun ImportSeedPhraseScreen(
AnimatedVisibility( AnimatedVisibility(
visible = visible, visible = visible,
enter = fadeIn(tween(500, delayMillis = 100)) enter = fadeIn(tween(500, delayMillis = 100)) + slideInVertically(
initialOffsetY = { -20 },
animationSpec = tween(500, delayMillis = 100)
)
) { ) {
Text( Text(
text = "Enter your 12-word recovery phrase", text = "Enter your 12-word recovery phrase\nto restore your account.",
fontSize = 15.sp, fontSize = 15.sp,
color = secondaryTextColor, color = secondaryTextColor,
textAlign = TextAlign.Center textAlign = TextAlign.Center,
lineHeight = 22.sp
) )
} }
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
// Paste button // Paste button - directly reads from clipboard
AnimatedVisibility( AnimatedVisibility(
visible = visible, visible = visible,
enter = fadeIn(tween(500, delayMillis = 200)) + scaleIn( enter = fadeIn(tween(500, delayMillis = 150)) + scaleIn(
initialScale = 0.9f, initialScale = 0.9f,
animationSpec = tween(500, delayMillis = 200) animationSpec = tween(500, delayMillis = 150)
) )
) { ) {
Button( TextButton(
onClick = { showPasteDialog = true }, onClick = {
modifier = Modifier val clipboardText = clipboardManager.getText()?.text
.fillMaxWidth() if (clipboardText != null && clipboardText.isNotBlank()) {
.height(50.dp), val parsed = clipboardText.trim().lowercase()
colors = ButtonDefaults.buttonColors( .split(Regex("[\\s,;]+"))
containerColor = PrimaryBlue, .filter { it.isNotBlank() }
contentColor = Color.White .map { it.trim() }
),
shape = RoundedCornerShape(12.dp) when {
) { parsed.size == 12 -> {
Icon( words = parsed.toList()
Icons.Default.ContentPaste, error = null
contentDescription = null, }
modifier = Modifier.size(22.dp) parsed.size > 12 -> {
) words = parsed.take(12).toList()
Spacer(modifier = Modifier.width(10.dp)) error = null
Text( }
"Paste All 12 Words", parsed.isNotEmpty() -> {
fontSize = 16.sp, error = "Clipboard contains ${parsed.size} words, need 12"
fontWeight = FontWeight.SemiBold }
) 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( AnimatedVisibility(
visible = visible, visible = visible,
enter = fadeIn(tween(500, delayMillis = 300)) enter = fadeIn(tween(500, delayMillis = 200))
) { ) {
Column( Row(
modifier = Modifier modifier = Modifier.fillMaxWidth(),
.fillMaxWidth() horizontalArrangement = Arrangement.spacedBy(12.dp)
.clip(RoundedCornerShape(12.dp)) ) {
.background(cardBackground) // Left column (words 1-6)
.padding(16.dp), Column(
verticalArrangement = Arrangement.spacedBy(12.dp) modifier = Modifier.weight(1f),
) { verticalArrangement = Arrangement.spacedBy(12.dp)
for (row in 0..3) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
for (col in 0..2) { for (i in 0..5) {
val index = row * 3 + col AnimatedWordInputItem(
WordInputItem( number = i + 1,
number = index + 1, value = words[i],
value = words[index],
onValueChange = { newValue -> onValueChange = { newValue ->
words = words.toMutableList().apply { words = words.toMutableList().apply {
this[index] = newValue.lowercase().trim() this[i] = newValue.lowercase().trim()
} }
error = null error = null
}, },
isDarkTheme = isDarkTheme, 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 // Error
if (error != null) { AnimatedVisibility(
Spacer(modifier = Modifier.height(12.dp)) visible = error != null,
AnimatedVisibility( enter = fadeIn(tween(300)) + scaleIn(initialScale = 0.9f),
visible = true, exit = fadeOut(tween(200))
enter = fadeIn(tween(300)) + scaleIn(initialScale = 0.9f) ) {
) { Column {
Spacer(modifier = Modifier.height(16.dp))
Text( Text(
text = error ?: "", text = error ?: "",
fontSize = 14.sp, fontSize = 14.sp,
@@ -217,95 +242,80 @@ fun ImportSeedPhraseScreen(
// Import button // Import button
AnimatedVisibility( AnimatedVisibility(
visible = visible, visible = visible,
enter = fadeIn(tween(500, delayMillis = 400)) + slideInVertically( enter = fadeIn(tween(500, delayMillis = 600)) + slideInVertically(
initialOffsetY = { 50 }, initialOffsetY = { 50 },
animationSpec = tween(500, delayMillis = 400) animationSpec = tween(500, delayMillis = 600)
) )
) { ) {
Button( Button(
onClick = { onClick = {
val seedPhrase = words.map { it.trim() } val seedPhrase = words.map { it.trim() }
if (seedPhrase.any { it.isBlank() }) { if (seedPhrase.any { it.isBlank() }) {
error = "Please fill in all words" error = "Please fill in all words"
return@Button return@Button
} }
if (!CryptoManager.validateSeedPhrase(seedPhrase)) { if (!CryptoManager.validateSeedPhrase(seedPhrase)) {
error = "Invalid recovery phrase" error = "Invalid recovery phrase"
return@Button return@Button
} }
onSeedPhraseImported(seedPhrase) onSeedPhraseImported(seedPhrase)
}, },
enabled = allWordsFilled, 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") },
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(120.dp), .height(50.dp),
colors = OutlinedTextFieldDefaults.colors( colors = ButtonDefaults.buttonColors(
focusedBorderColor = PrimaryBlue, containerColor = PrimaryBlue,
cursorColor = PrimaryBlue contentColor = Color.White,
disabledContainerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8),
disabledContentColor = if (isDarkTheme) Color(0xFF666666) else Color(0xFF999999)
), ),
shape = RoundedCornerShape(12.dp) 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 }) { Spacer(modifier = Modifier.height(40.dp))
Text("Cancel") }
}
},
containerColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color.White,
shape = RoundedCornerShape(20.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 @Composable
private fun WordInputItem( private fun WordInputItem(
number: Int, number: Int,
@@ -314,55 +324,81 @@ private fun WordInputItem(
isDarkTheme: Boolean, isDarkTheme: Boolean,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val itemBg = if (isDarkTheme) Color(0xFF1E1E1E) else Color.White val numberColor = if (isDarkTheme) Color(0xFF888888) else Color(0xFF999999)
val numberColor = if (isDarkTheme) Color(0xFF666666) else Color(0xFFAAAAAA) val hintColor = if (isDarkTheme) Color(0xFF555555) else Color(0xFFBBBBBB)
val textColor = if (isDarkTheme) Color.White else Color.Black val bgColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFF5F5F5)
val hintColor = if (isDarkTheme) Color(0xFF666666) else Color(0xFFAAAAAA)
var isFocused by remember { mutableStateOf(false) } 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 modifier = modifier
.clip(RoundedCornerShape(8.dp)) .fillMaxWidth()
.border(1.dp, borderColor, RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(12.dp))
.background(itemBg) .border(
.padding(horizontal = 10.dp, vertical = 10.dp), width = if (isFocused) 2.dp else 0.dp,
verticalAlignment = Alignment.CenterVertically color = if (isFocused) PrimaryBlue else Color.Transparent,
shape = RoundedCornerShape(12.dp)
)
.background(bgColor)
.padding(horizontal = 16.dp, vertical = 14.dp)
) { ) {
Text( Row(
text = "$number.", verticalAlignment = Alignment.CenterVertically,
fontSize = 13.sp, modifier = Modifier.fillMaxWidth()
color = numberColor, ) {
modifier = Modifier.width(22.dp) Text(
) text = "$number.",
BasicTextField(
value = value,
onValueChange = onValueChange,
textStyle = TextStyle(
fontSize = 15.sp, fontSize = 15.sp,
fontWeight = FontWeight.Medium, color = numberColor,
color = textColor, modifier = Modifier.width(28.dp)
fontFamily = FontFamily.Monospace )
), BasicTextField(
singleLine = true, value = value,
cursorBrush = SolidColor(PrimaryBlue), onValueChange = onValueChange,
modifier = Modifier textStyle = TextStyle(
.weight(1f) fontSize = 17.sp,
.onFocusChanged { isFocused = it.isFocused }, fontWeight = FontWeight.SemiBold,
decorationBox = { innerTextField -> color = if (value.isNotEmpty()) wordColor else hintColor,
Box { fontFamily = FontFamily.Monospace
if (value.isEmpty()) { ),
Text( singleLine = true,
"word", cursorBrush = SolidColor(PrimaryBlue),
fontSize = 15.sp, modifier = Modifier
color = hintColor.copy(alpha = 0.5f), .weight(1f)
fontFamily = FontFamily.Monospace .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()
} }
} )
) }
} }
} }

View File

@@ -12,6 +12,7 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@@ -248,31 +249,52 @@ private fun WordItem(
isDarkTheme: Boolean, isDarkTheme: Boolean,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val itemBg = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFF5F5F5) val numberColor = if (isDarkTheme) Color(0xFF888888) else Color(0xFF999999)
val numberColor = if (isDarkTheme) Color(0xFF666666) else Color(0xFFAAAAAA)
val wordColor = if (isDarkTheme) Color.White else Color.Black
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 modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.clip(RoundedCornerShape(12.dp)) .clip(RoundedCornerShape(12.dp))
.background(itemBg) .background(bgColor)
.padding(horizontal = 16.dp, vertical = 14.dp), .padding(horizontal = 16.dp, vertical = 14.dp)
verticalAlignment = Alignment.CenterVertically
) { ) {
Text( Row(
text = "$number.", verticalAlignment = Alignment.CenterVertically,
fontSize = 15.sp, modifier = Modifier.fillMaxWidth()
color = numberColor, ) {
modifier = Modifier.width(28.dp) Text(
) text = "$number.",
Text( fontSize = 15.sp,
text = word, color = numberColor,
fontSize = 17.sp, modifier = Modifier.width(28.dp)
fontWeight = FontWeight.SemiBold, )
color = wordColor, Text(
fontFamily = FontFamily.Monospace text = word,
) fontSize = 17.sp,
fontWeight = FontWeight.SemiBold,
color = wordColor,
fontFamily = FontFamily.Monospace
)
}
} }
} }

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 @Composable
fun UnlockScreen( fun UnlockScreen(
isDarkTheme: Boolean, 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 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 backgroundColor by animateColorAsState(if (isDarkTheme) AuthBackground else AuthBackgroundLight, animationSpec = themeAnimSpec)
@@ -56,7 +58,7 @@ fun UnlockScreen(
// Load current account // Load current account
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
currentPublicKey = accountManager.currentPublicKey.first() currentPublicKey = selectedAccountId ?: accountManager.currentPublicKey.first()
} }
// Entry animation // 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)) Spacer(modifier = Modifier.weight(0.3f))
} }
} }

View File

@@ -45,7 +45,8 @@ fun WelcomeScreen(
val lockComposition by rememberLottieComposition(LottieCompositionSpec.Asset("lottie/lock.json")) val lockComposition by rememberLottieComposition(LottieCompositionSpec.Asset("lottie/lock.json"))
val lockProgress by animateLottieCompositionAsState( val lockProgress by animateLottieCompositionAsState(
composition = lockComposition, composition = lockComposition,
iterations = LottieConstants.IterateForever iterations = 1, // Play once
speed = 1f
) )
// Entry animation // 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.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerDefaults
import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
@@ -238,12 +239,23 @@ fun OnboardingScreen(
Spacer(modifier = Modifier.height(32.dp)) Spacer(modifier = Modifier.height(32.dp))
// Pager for text content // Pager for text content with easier swipes
HorizontalPager( HorizontalPager(
state = pagerState, state = pagerState,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .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 -> ) { page ->
OnboardingPageContent( OnboardingPageContent(
page = onboardingPages[page], page = onboardingPages[page],
@@ -381,14 +393,44 @@ fun AnimatedRosettaLogo(
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
// Pre-render all animations to avoid lag // Pre-render all animations to avoid lag
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
// Rosetta icon (page 0) // Rosetta icon (page 0) with pulse animation
if (currentPage == 0) { 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( 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), painter = painterResource(id = R.drawable.rosetta_icon),
contentDescription = "Rosetta Logo", contentDescription = "Rosetta Logo",
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.scale(pulseScale)
.clip(CircleShape) .clip(CircleShape)
) )
} }