feat: Enhance UnlockScreen with account selection dropdown and search functionality

This commit is contained in:
k1ngsterr1
2026-01-09 02:48:34 +05:00
parent e29a851510
commit d3b25ae64f
5 changed files with 404 additions and 160 deletions

View File

@@ -130,9 +130,9 @@ fun AuthFlow(
selectedAccountId = selectedAccountId,
onUnlocked = { account -> onAuthComplete(account) },
onSwitchAccount = {
if (accounts.size > 1) {
currentScreen = AuthScreen.SELECT_ACCOUNT
}
// Navigate to create new account screen
currentScreen = AuthScreen.WELCOME
selectedAccountId = null
}
)
}

View File

@@ -58,6 +58,24 @@ fun SetPasswordScreen(
var error by remember { mutableStateOf<String?>(null) }
var visible by remember { mutableStateOf(false) }
// Track keyboard visibility
val view = androidx.compose.ui.platform.LocalView.current
var isKeyboardVisible by remember { mutableStateOf(false) }
DisposableEffect(view) {
val listener = android.view.ViewTreeObserver.OnGlobalLayoutListener {
val rect = android.graphics.Rect()
view.getWindowVisibleDisplayFrame(rect)
val screenHeight = view.rootView.height
val keypadHeight = screenHeight - rect.bottom
isKeyboardVisible = keypadHeight > screenHeight * 0.15
}
view.viewTreeObserver.addOnGlobalLayoutListener(listener)
onDispose {
view.viewTreeObserver.removeOnGlobalLayoutListener(listener)
}
}
LaunchedEffect(Unit) {
visible = true
}
@@ -109,9 +127,18 @@ fun SetPasswordScreen(
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(16.dp))
Spacer(modifier = Modifier.height(if (isKeyboardVisible) 8.dp else 16.dp))
// Lock Icon - smaller when keyboard is visible
val iconSize by animateDpAsState(
targetValue = if (isKeyboardVisible) 48.dp else 80.dp,
animationSpec = tween(300, easing = FastOutSlowInEasing)
)
val iconInnerSize by animateDpAsState(
targetValue = if (isKeyboardVisible) 24.dp else 40.dp,
animationSpec = tween(300, easing = FastOutSlowInEasing)
)
// Lock Icon
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(500)) + scaleIn(
@@ -121,8 +148,8 @@ fun SetPasswordScreen(
) {
Box(
modifier = Modifier
.size(80.dp)
.clip(RoundedCornerShape(20.dp))
.size(iconSize)
.clip(RoundedCornerShape(if (isKeyboardVisible) 12.dp else 20.dp))
.background(PrimaryBlue.copy(alpha = 0.1f)),
contentAlignment = Alignment.Center
) {
@@ -130,12 +157,12 @@ fun SetPasswordScreen(
Icons.Default.Lock,
contentDescription = null,
tint = PrimaryBlue,
modifier = Modifier.size(40.dp)
modifier = Modifier.size(iconInnerSize)
)
}
}
Spacer(modifier = Modifier.height(24.dp))
Spacer(modifier = Modifier.height(if (isKeyboardVisible) 12.dp else 24.dp))
AnimatedVisibility(
visible = visible,
@@ -146,13 +173,13 @@ fun SetPasswordScreen(
) {
Text(
text = "Protect Your Account",
fontSize = 24.sp,
fontSize = if (isKeyboardVisible) 20.sp else 24.sp,
fontWeight = FontWeight.Bold,
color = textColor
)
}
Spacer(modifier = Modifier.height(8.dp))
Spacer(modifier = Modifier.height(if (isKeyboardVisible) 6.dp else 8.dp))
AnimatedVisibility(
visible = visible,
@@ -160,14 +187,14 @@ fun SetPasswordScreen(
) {
Text(
text = "This password encrypts your keys locally.\nYou'll need it to unlock Rosetta.",
fontSize = 14.sp,
fontSize = if (isKeyboardVisible) 12.sp else 14.sp,
color = secondaryTextColor,
textAlign = TextAlign.Center,
lineHeight = 20.sp
lineHeight = if (isKeyboardVisible) 16.sp else 20.sp
)
}
Spacer(modifier = Modifier.height(32.dp))
Spacer(modifier = Modifier.height(if (isKeyboardVisible) 16.dp else 32.dp))
// Password Field
AnimatedVisibility(

View File

@@ -5,6 +5,8 @@ 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.foundation.text.KeyboardOptions
@@ -15,7 +17,11 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
@@ -24,18 +30,28 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
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.R
import com.rosetta.messenger.crypto.CryptoManager
import com.rosetta.messenger.data.AccountManager
import com.rosetta.messenger.data.DecryptedAccount
import com.rosetta.messenger.data.EncryptedAccount
import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.network.ProtocolState
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import com.rosetta.messenger.ui.chats.getAvatarColor
import com.rosetta.messenger.ui.chats.getAvatarText
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
// Account model for dropdown
data class AccountItem(
val publicKey: String,
val name: String,
val encryptedAccount: EncryptedAccount
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun UnlockScreen(
@@ -48,6 +64,7 @@ fun UnlockScreen(
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 cardBackground by animateColorAsState(if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFF5F5F5), animationSpec = themeAnimSpec)
val context = LocalContext.current
val accountManager = remember { AccountManager(context) }
@@ -57,17 +74,63 @@ fun UnlockScreen(
var passwordVisible by remember { mutableStateOf(false) }
var isUnlocking by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
var currentPublicKey by remember { mutableStateOf<String?>(null) }
// Load current account
// Account selection state
var accounts by remember { mutableStateOf<List<AccountItem>>(emptyList()) }
var selectedAccount by remember { mutableStateOf<AccountItem?>(null) }
var isDropdownExpanded by remember { mutableStateOf(false) }
var searchQuery by remember { mutableStateOf("") }
val searchFocusRequester = remember { FocusRequester() }
// Load accounts
LaunchedEffect(Unit) {
currentPublicKey = selectedAccountId ?: accountManager.currentPublicKey.first()
val allAccounts = accountManager.getAllAccounts()
accounts = allAccounts.map { acc ->
AccountItem(
publicKey = acc.publicKey,
name = acc.name,
encryptedAccount = acc
)
}
// Select account
val targetPublicKey = selectedAccountId ?: accountManager.currentPublicKey.first()
selectedAccount = accounts.find { it.publicKey == targetPublicKey } ?: accounts.firstOrNull()
}
// Filter accounts by search
val filteredAccounts = remember(searchQuery, accounts) {
if (searchQuery.isEmpty()) accounts
else accounts.filter {
it.name.contains(searchQuery, ignoreCase = true) ||
it.publicKey.contains(searchQuery, ignoreCase = true)
}
}
// Entry animation
var visible by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { visible = true }
// Dropdown animation
val dropdownProgress by animateFloatAsState(
targetValue = if (isDropdownExpanded) 1f else 0f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow
),
label = "dropdownProgress"
)
// Auto-focus search when dropdown opens
LaunchedEffect(isDropdownExpanded) {
if (isDropdownExpanded) {
kotlinx.coroutines.delay(200)
searchFocusRequester.requestFocus()
} else {
searchQuery = ""
}
}
Box(
modifier = Modifier
.fillMaxSize()
@@ -76,12 +139,13 @@ fun UnlockScreen(
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.imePadding()
.padding(horizontal = 24.dp)
.statusBarsPadding(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.weight(0.2f))
Spacer(modifier = Modifier.height(40.dp))
// Rosetta Logo
AnimatedVisibility(
@@ -92,12 +156,12 @@ fun UnlockScreen(
painter = painterResource(id = R.drawable.rosetta_icon),
contentDescription = "Rosetta",
modifier = Modifier
.size(120.dp)
.size(100.dp)
.clip(CircleShape)
)
}
Spacer(modifier = Modifier.height(32.dp))
Spacer(modifier = Modifier.height(24.dp))
// Title
AnimatedVisibility(
@@ -117,24 +181,263 @@ fun UnlockScreen(
Spacer(modifier = Modifier.height(8.dp))
// Account info
AnimatedVisibility(
visible = visible && currentPublicKey != null,
visible = visible,
enter = fadeIn(tween(600, delayMillis = 300))
) {
Text(
text = "Enter your password to unlock",
text = "Select your account and enter password",
fontSize = 16.sp,
color = secondaryTextColor,
textAlign = TextAlign.Center
)
}
Spacer(modifier = Modifier.height(48.dp))
Spacer(modifier = Modifier.height(32.dp))
// Account Selector Card
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(600, delayMillis = 350)) + slideInVertically(
initialOffsetY = { 50 },
animationSpec = tween(600, delayMillis = 350)
)
) {
Column {
// Account selector dropdown
Card(
modifier = Modifier
.fillMaxWidth()
.clickable { isDropdownExpanded = !isDropdownExpanded },
colors = CardDefaults.cardColors(containerColor = cardBackground),
shape = RoundedCornerShape(16.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Avatar
if (selectedAccount != null) {
val avatarColors = getAvatarColor(selectedAccount!!.name, isDarkTheme)
Box(
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.background(avatarColors.backgroundColor),
contentAlignment = Alignment.Center
) {
Text(
text = getAvatarText(selectedAccount!!.publicKey),
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
color = avatarColors.textColor
)
}
}
Spacer(modifier = Modifier.width(12.dp))
// Account info
Column(modifier = Modifier.weight(1f)) {
Text(
text = selectedAccount?.name ?: "Select Account",
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
color = textColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (selectedAccount != null) {
Text(
text = selectedAccount!!.publicKey.take(20) + "...",
fontSize = 13.sp,
color = secondaryTextColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
// Dropdown arrow with rotation
Icon(
imageVector = Icons.Default.KeyboardArrowDown,
contentDescription = null,
tint = secondaryTextColor,
modifier = Modifier
.size(24.dp)
.graphicsLayer {
rotationZ = 180f * dropdownProgress
}
)
}
}
// Dropdown list with animation
AnimatedVisibility(
visible = isDropdownExpanded,
enter = fadeIn(tween(200)) + expandVertically(
expandFrom = Alignment.Top,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessMediumLow
)
),
exit = fadeOut(tween(150)) + shrinkVertically(
shrinkTowards = Alignment.Top,
animationSpec = tween(200)
)
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp)
.heightIn(max = 300.dp),
colors = CardDefaults.cardColors(containerColor = cardBackground),
shape = RoundedCornerShape(16.dp)
) {
Column {
// Search field
OutlinedTextField(
value = searchQuery,
onValueChange = { searchQuery = it },
placeholder = {
Text(
"Search accounts...",
color = secondaryTextColor.copy(alpha = 0.6f)
)
},
leadingIcon = {
Icon(
Icons.Default.Search,
contentDescription = null,
tint = secondaryTextColor
)
},
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = PrimaryBlue,
unfocusedBorderColor = Color.Transparent,
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
focusedTextColor = textColor,
unfocusedTextColor = textColor,
cursorColor = PrimaryBlue
),
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp)
.focusRequester(searchFocusRequester),
shape = RoundedCornerShape(12.dp)
)
Divider(
color = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE0E0E0),
thickness = 0.5.dp
)
// Account list
LazyColumn(
modifier = Modifier.fillMaxWidth()
) {
items(filteredAccounts, key = { it.publicKey }) { account ->
val isSelected = account.publicKey == selectedAccount?.publicKey
val itemScale by animateFloatAsState(
targetValue = if (isSelected) 1f else 0.98f,
label = "itemScale"
)
Row(
modifier = Modifier
.fillMaxWidth()
.scale(itemScale)
.clickable {
selectedAccount = account
isDropdownExpanded = false
password = ""
error = null
}
.background(
if (isSelected) PrimaryBlue.copy(alpha = 0.1f)
else Color.Transparent
)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Avatar
val avatarColors = getAvatarColor(account.name, isDarkTheme)
Box(
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(avatarColors.backgroundColor),
contentAlignment = Alignment.Center
) {
Text(
text = getAvatarText(account.publicKey),
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = avatarColors.textColor
)
}
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = account.name,
fontSize = 15.sp,
fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal,
color = textColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = account.publicKey.take(16) + "...",
fontSize = 12.sp,
color = secondaryTextColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
if (isSelected) {
Icon(
Icons.Default.Check,
contentDescription = null,
tint = PrimaryBlue,
modifier = Modifier.size(20.dp)
)
}
}
}
if (filteredAccounts.isEmpty()) {
item {
Text(
text = "No accounts found",
color = secondaryTextColor,
fontSize = 14.sp,
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
textAlign = TextAlign.Center
)
}
}
}
}
}
}
}
}
Spacer(modifier = Modifier.height(20.dp))
// Password Field
AnimatedVisibility(
visible = visible,
visible = visible && !isDropdownExpanded,
enter = fadeIn(tween(600, delayMillis = 400)) + slideInVertically(
initialOffsetY = { 50 },
animationSpec = tween(600, delayMillis = 400)
@@ -197,7 +500,7 @@ fun UnlockScreen(
// Unlock Button
AnimatedVisibility(
visible = visible,
visible = visible && !isDropdownExpanded,
enter = fadeIn(tween(600, delayMillis = 500)) + slideInVertically(
initialOffsetY = { 50 },
animationSpec = tween(600, delayMillis = 500)
@@ -205,6 +508,10 @@ fun UnlockScreen(
) {
Button(
onClick = {
if (selectedAccount == null) {
error = "Please select an account"
return@Button
}
if (password.isEmpty()) {
error = "Please enter your password"
return@Button
@@ -213,20 +520,7 @@ fun UnlockScreen(
isUnlocking = true
scope.launch {
try {
val publicKey = currentPublicKey ?: run {
error = "No account found"
isUnlocking = false
return@launch
}
val accounts = accountManager.getAllAccounts()
val account = accounts.find { it.publicKey == publicKey }
if (account == null) {
error = "Account not found"
isUnlocking = false
return@launch
}
val account = selectedAccount!!.encryptedAccount
// Try to decrypt
val decryptedPrivateKey = CryptoManager.decryptWithPassword(
@@ -253,20 +547,20 @@ fun UnlockScreen(
name = account.name
)
// 🔌 Connect to server and authenticate
Log.d("UnlockScreen", "🔌 Connecting to server...")
// Connect to server and authenticate
Log.d("UnlockScreen", "Connecting to server...")
ProtocolManager.authenticate(account.publicKey, privateKeyHash)
accountManager.setCurrentAccount(publicKey)
accountManager.setCurrentAccount(account.publicKey)
onUnlocked(decryptedAccount)
} catch (e: Exception) {
error = "Failed to unlock: ${e.message}"
error = "Failed to unlock: \${e.message}"
isUnlocking = false
}
}
},
enabled = password.isNotEmpty() && !isUnlocking,
enabled = selectedAccount != null && password.isNotEmpty() && !isUnlocking,
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
@@ -302,9 +596,9 @@ fun UnlockScreen(
Spacer(modifier = Modifier.height(16.dp))
// Switch Account button
// Create New Account button
AnimatedVisibility(
visible = visible,
visible = visible && !isDropdownExpanded,
enter = fadeIn(tween(600, delayMillis = 600)) + slideInVertically(
initialOffsetY = { 50 },
animationSpec = tween(600, delayMillis = 600)
@@ -314,21 +608,21 @@ fun UnlockScreen(
onClick = onSwitchAccount
) {
Icon(
imageVector = Icons.Default.SwapHoriz,
imageVector = Icons.Default.PersonAdd,
contentDescription = null,
tint = PrimaryBlue,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Switch Account",
text = "Create New Account",
color = PrimaryBlue,
fontSize = 15.sp
)
}
}
Spacer(modifier = Modifier.weight(0.3f))
Spacer(modifier = Modifier.height(60.dp))
}
}
}