feat: Enhance UnlockScreen with account selection dropdown and search functionality

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

View File

@@ -201,7 +201,7 @@ fun MainScreen(
onToggleTheme: () -> Unit = {}, onToggleTheme: () -> Unit = {},
onLogout: () -> Unit = {} onLogout: () -> Unit = {}
) { ) {
val accountName = account?.name ?: "Rosetta User" val accountName = account?.publicKey ?: "04c266b98ae5"
val accountPhone = account?.publicKey?.take(16)?.let { val accountPhone = account?.publicKey?.take(16)?.let {
"+${it.take(1)} ${it.substring(1, 4)} ${it.substring(4, 7)}${it.substring(7)}" "+${it.take(1)} ${it.substring(1, 4)} ${it.substring(4, 7)}${it.substring(7)}"
} ?: "+7 775 9932587" } ?: "+7 775 9932587"

View File

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

View File

@@ -58,6 +58,24 @@ fun SetPasswordScreen(
var error by remember { mutableStateOf<String?>(null) } var error by remember { mutableStateOf<String?>(null) }
var visible by remember { mutableStateOf(false) } 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) { LaunchedEffect(Unit) {
visible = true visible = true
} }
@@ -109,9 +127,18 @@ fun SetPasswordScreen(
.verticalScroll(rememberScrollState()), .verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally 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( AnimatedVisibility(
visible = visible, visible = visible,
enter = fadeIn(tween(500)) + scaleIn( enter = fadeIn(tween(500)) + scaleIn(
@@ -121,8 +148,8 @@ fun SetPasswordScreen(
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier
.size(80.dp) .size(iconSize)
.clip(RoundedCornerShape(20.dp)) .clip(RoundedCornerShape(if (isKeyboardVisible) 12.dp else 20.dp))
.background(PrimaryBlue.copy(alpha = 0.1f)), .background(PrimaryBlue.copy(alpha = 0.1f)),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
@@ -130,12 +157,12 @@ fun SetPasswordScreen(
Icons.Default.Lock, Icons.Default.Lock,
contentDescription = null, contentDescription = null,
tint = PrimaryBlue, 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( AnimatedVisibility(
visible = visible, visible = visible,
@@ -146,13 +173,13 @@ fun SetPasswordScreen(
) { ) {
Text( Text(
text = "Protect Your Account", text = "Protect Your Account",
fontSize = 24.sp, fontSize = if (isKeyboardVisible) 20.sp else 24.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = textColor color = textColor
) )
} }
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(if (isKeyboardVisible) 6.dp else 8.dp))
AnimatedVisibility( AnimatedVisibility(
visible = visible, visible = visible,
@@ -160,14 +187,14 @@ fun SetPasswordScreen(
) { ) {
Text( Text(
text = "This password encrypts your keys locally.\nYou'll need it to unlock Rosetta.", 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, color = secondaryTextColor,
textAlign = TextAlign.Center, 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 // Password Field
AnimatedVisibility( AnimatedVisibility(

View File

@@ -5,6 +5,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.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
@@ -15,7 +17,11 @@ 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.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.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight 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.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.rosetta.messenger.R import com.rosetta.messenger.R
import com.rosetta.messenger.crypto.CryptoManager import com.rosetta.messenger.crypto.CryptoManager
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.EncryptedAccount
import com.rosetta.messenger.network.ProtocolManager import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.network.ProtocolState
import com.rosetta.messenger.ui.onboarding.PrimaryBlue 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.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
// Account model for dropdown
data class AccountItem(
val publicKey: String,
val name: String,
val encryptedAccount: EncryptedAccount
)
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun UnlockScreen( fun UnlockScreen(
@@ -48,6 +64,7 @@ fun UnlockScreen(
val backgroundColor by animateColorAsState(if (isDarkTheme) AuthBackground else AuthBackgroundLight, animationSpec = themeAnimSpec) 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 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 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 context = LocalContext.current
val accountManager = remember { AccountManager(context) } val accountManager = remember { AccountManager(context) }
@@ -57,17 +74,63 @@ fun UnlockScreen(
var passwordVisible by remember { mutableStateOf(false) } var passwordVisible by remember { mutableStateOf(false) }
var isUnlocking by remember { mutableStateOf(false) } var isUnlocking by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) } 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) { 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 // Entry animation
var visible by remember { mutableStateOf(false) } var visible by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { visible = true } 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( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -76,12 +139,13 @@ fun UnlockScreen(
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.verticalScroll(rememberScrollState())
.imePadding() .imePadding()
.padding(horizontal = 24.dp) .padding(horizontal = 24.dp)
.statusBarsPadding(), .statusBarsPadding(),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Spacer(modifier = Modifier.weight(0.2f)) Spacer(modifier = Modifier.height(40.dp))
// Rosetta Logo // Rosetta Logo
AnimatedVisibility( AnimatedVisibility(
@@ -92,12 +156,12 @@ fun UnlockScreen(
painter = painterResource(id = R.drawable.rosetta_icon), painter = painterResource(id = R.drawable.rosetta_icon),
contentDescription = "Rosetta", contentDescription = "Rosetta",
modifier = Modifier modifier = Modifier
.size(120.dp) .size(100.dp)
.clip(CircleShape) .clip(CircleShape)
) )
} }
Spacer(modifier = Modifier.height(32.dp)) Spacer(modifier = Modifier.height(24.dp))
// Title // Title
AnimatedVisibility( AnimatedVisibility(
@@ -117,24 +181,263 @@ fun UnlockScreen(
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
// Account info
AnimatedVisibility( AnimatedVisibility(
visible = visible && currentPublicKey != null, visible = visible,
enter = fadeIn(tween(600, delayMillis = 300)) enter = fadeIn(tween(600, delayMillis = 300))
) { ) {
Text( Text(
text = "Enter your password to unlock", text = "Select your account and enter password",
fontSize = 16.sp, fontSize = 16.sp,
color = secondaryTextColor, color = secondaryTextColor,
textAlign = TextAlign.Center 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 // Password Field
AnimatedVisibility( AnimatedVisibility(
visible = visible, visible = visible && !isDropdownExpanded,
enter = fadeIn(tween(600, delayMillis = 400)) + slideInVertically( enter = fadeIn(tween(600, delayMillis = 400)) + slideInVertically(
initialOffsetY = { 50 }, initialOffsetY = { 50 },
animationSpec = tween(600, delayMillis = 400) animationSpec = tween(600, delayMillis = 400)
@@ -197,7 +500,7 @@ fun UnlockScreen(
// Unlock Button // Unlock Button
AnimatedVisibility( AnimatedVisibility(
visible = visible, visible = visible && !isDropdownExpanded,
enter = fadeIn(tween(600, delayMillis = 500)) + slideInVertically( enter = fadeIn(tween(600, delayMillis = 500)) + slideInVertically(
initialOffsetY = { 50 }, initialOffsetY = { 50 },
animationSpec = tween(600, delayMillis = 500) animationSpec = tween(600, delayMillis = 500)
@@ -205,6 +508,10 @@ fun UnlockScreen(
) { ) {
Button( Button(
onClick = { onClick = {
if (selectedAccount == null) {
error = "Please select an account"
return@Button
}
if (password.isEmpty()) { if (password.isEmpty()) {
error = "Please enter your password" error = "Please enter your password"
return@Button return@Button
@@ -213,20 +520,7 @@ fun UnlockScreen(
isUnlocking = true isUnlocking = true
scope.launch { scope.launch {
try { try {
val publicKey = currentPublicKey ?: run { val account = selectedAccount!!.encryptedAccount
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
}
// Try to decrypt // Try to decrypt
val decryptedPrivateKey = CryptoManager.decryptWithPassword( val decryptedPrivateKey = CryptoManager.decryptWithPassword(
@@ -253,20 +547,20 @@ fun UnlockScreen(
name = account.name name = account.name
) )
// 🔌 Connect to server and authenticate // Connect to server and authenticate
Log.d("UnlockScreen", "🔌 Connecting to server...") Log.d("UnlockScreen", "Connecting to server...")
ProtocolManager.authenticate(account.publicKey, privateKeyHash) ProtocolManager.authenticate(account.publicKey, privateKeyHash)
accountManager.setCurrentAccount(publicKey) accountManager.setCurrentAccount(account.publicKey)
onUnlocked(decryptedAccount) onUnlocked(decryptedAccount)
} catch (e: Exception) { } catch (e: Exception) {
error = "Failed to unlock: ${e.message}" error = "Failed to unlock: \${e.message}"
isUnlocking = false isUnlocking = false
} }
} }
}, },
enabled = password.isNotEmpty() && !isUnlocking, enabled = selectedAccount != null && password.isNotEmpty() && !isUnlocking,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(56.dp), .height(56.dp),
@@ -302,9 +596,9 @@ fun UnlockScreen(
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
// Switch Account button // Create New Account button
AnimatedVisibility( AnimatedVisibility(
visible = visible, visible = visible && !isDropdownExpanded,
enter = fadeIn(tween(600, delayMillis = 600)) + slideInVertically( enter = fadeIn(tween(600, delayMillis = 600)) + slideInVertically(
initialOffsetY = { 50 }, initialOffsetY = { 50 },
animationSpec = tween(600, delayMillis = 600) animationSpec = tween(600, delayMillis = 600)
@@ -314,21 +608,21 @@ fun UnlockScreen(
onClick = onSwitchAccount onClick = onSwitchAccount
) { ) {
Icon( Icon(
imageVector = Icons.Default.SwapHoriz, imageVector = Icons.Default.PersonAdd,
contentDescription = null, contentDescription = null,
tint = PrimaryBlue, tint = PrimaryBlue,
modifier = Modifier.size(20.dp) modifier = Modifier.size(20.dp)
) )
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Text( Text(
text = "Switch Account", text = "Create New Account",
color = PrimaryBlue, color = PrimaryBlue,
fontSize = 15.sp fontSize = 15.sp
) )
} }
} }
Spacer(modifier = Modifier.weight(0.3f)) Spacer(modifier = Modifier.height(60.dp))
} }
} }
} }

View File

@@ -148,73 +148,30 @@ fun ChatsListScreen(
onNewChat: () -> Unit, onNewChat: () -> Unit,
onLogout: () -> Unit onLogout: () -> Unit
) { ) {
// Theme transition animation state // Theme transition state
var isTransitioning by remember { mutableStateOf(false) }
var transitionProgress by remember { mutableStateOf(0f) }
var clickPosition by remember { mutableStateOf(Offset.Zero) }
var shouldUpdateStatusBar by remember { mutableStateOf(false) }
var hasInitialized by remember { mutableStateOf(false) } var hasInitialized by remember { mutableStateOf(false) }
var previousTheme by remember { mutableStateOf(isDarkTheme) }
var targetTheme by remember { mutableStateOf(isDarkTheme) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
hasInitialized = true hasInitialized = true
} }
// Theme transition animation
LaunchedEffect(isTransitioning) {
if (isTransitioning) {
shouldUpdateStatusBar = false
val duration = 800f
val startTime = System.currentTimeMillis()
while (transitionProgress < 1f) {
val elapsed = System.currentTimeMillis() - startTime
transitionProgress = (elapsed / duration).coerceAtMost(1f)
kotlinx.coroutines.delay(16)
}
shouldUpdateStatusBar = true
kotlinx.coroutines.delay(50)
isTransitioning = false
transitionProgress = 0f
shouldUpdateStatusBar = false
previousTheme = targetTheme
}
}
val view = androidx.compose.ui.platform.LocalView.current val view = androidx.compose.ui.platform.LocalView.current
// Animate navigation bar color starting at 80% of wave animation // Update status bar and navigation bar
LaunchedEffect(isTransitioning, transitionProgress) { LaunchedEffect(isDarkTheme, drawerState.isOpen) {
if (isTransitioning && transitionProgress >= 0.8f && !view.isInEditMode) { if (!view.isInEditMode) {
val window = (view.context as android.app.Activity).window
val navProgress = ((transitionProgress - 0.8f) / 0.2f).coerceIn(0f, 1f)
val oldColor = if (previousTheme) 0xFF1A1A1A else 0xFFFFFFFF
val newColor = if (targetTheme) 0xFF1A1A1A else 0xFFFFFFFF
val r1 = (oldColor shr 16 and 0xFF)
val g1 = (oldColor shr 8 and 0xFF)
val b1 = (oldColor and 0xFF)
val r2 = (newColor shr 16 and 0xFF)
val g2 = (newColor shr 8 and 0xFF)
val b2 = (newColor and 0xFF)
val r = (r1 + (r2 - r1) * navProgress).toInt()
val g = (g1 + (g2 - g1) * navProgress).toInt()
val b = (b1 + (b2 - b1) * navProgress).toInt()
window.navigationBarColor = (0xFF000000 or (r.toLong() shl 16) or (g.toLong() shl 8) or b.toLong()).toInt()
}
}
// Update status bar icons when animation finishes
LaunchedEffect(shouldUpdateStatusBar) {
if (shouldUpdateStatusBar && !view.isInEditMode) {
val window = (view.context as android.app.Activity).window val window = (view.context as android.app.Activity).window
val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view) val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view)
insetsController.isAppearanceLightStatusBars = !isDarkTheme insetsController.isAppearanceLightStatusBars = !isDarkTheme
insetsController.isAppearanceLightNavigationBars = !isDarkTheme insetsController.isAppearanceLightNavigationBars = !isDarkTheme
window.statusBarColor = android.graphics.Color.TRANSPARENT window.statusBarColor = android.graphics.Color.TRANSPARENT
// When drawer is open, dim the navigation bar to match overlay
window.navigationBarColor = if (drawerState.isOpen) {
// Darker color to match scrim overlay
if (isDarkTheme) 0xFF0D0D0D.toInt() else 0xFF999999.toInt()
} else {
if (isDarkTheme) 0xFF1A1A1A.toInt() else 0xFFFFFFFF.toInt()
}
} }
} }
@@ -371,30 +328,13 @@ fun ChatsListScreen(
) )
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
// Base background - shows the OLD theme color during transition // Simple background
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(if (isTransitioning) { .background(backgroundColor)
if (previousTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF)
} else backgroundColor)
) )
// Circular reveal overlay - draws the NEW theme color expanding
if (isTransitioning) {
Canvas(modifier = Modifier.fillMaxSize()) {
val maxRadius = kotlin.math.hypot(size.width, size.height)
val radius = maxRadius * transitionProgress
// Draw the NEW theme color expanding from click point
drawCircle(
color = if (targetTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF),
radius = radius,
center = clickPosition
)
}
}
ModalNavigationDrawer( ModalNavigationDrawer(
drawerState = drawerState, drawerState = drawerState,
drawerContent = { drawerContent = {
@@ -406,8 +346,9 @@ fun ChatsListScreen(
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.background(if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFF5F5F5)) .background(drawerBackgroundColor)
.padding(16.dp) .padding(top = 48.dp)
.padding(horizontal = 16.dp, vertical = 16.dp)
) { ) {
Column { Column {
Row( Row(
@@ -434,28 +375,7 @@ fun ChatsListScreen(
// Theme toggle // Theme toggle
IconButton( IconButton(
onClick = {}, onClick = onToggleTheme
modifier = Modifier.onGloballyPositioned { coordinates ->
// This will be handled by clickable below
}
) {
Box(
modifier = Modifier
.clickable {
if (!isTransitioning) {
previousTheme = isDarkTheme
targetTheme = !isDarkTheme
// Use center of icon as click position
val screenWidth = view.width.toFloat()
val screenHeight = view.height.toFloat()
clickPosition = Offset(
screenWidth - 48.dp.value * view.resources.displayMetrics.density,
96.dp.value * view.resources.displayMetrics.density
)
isTransitioning = true
onToggleTheme()
}
}
) { ) {
Icon( Icon(
if (isDarkTheme) Icons.Default.LightMode else Icons.Default.DarkMode, if (isDarkTheme) Icons.Default.LightMode else Icons.Default.DarkMode,
@@ -464,16 +384,16 @@ fun ChatsListScreen(
) )
} }
} }
}
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
// Account name // Public key instead of account name
Text( Text(
text = accountName, text = accountPublicKey,
fontSize = 18.sp, fontSize = 14.sp,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.Medium,
color = textColor color = secondaryTextColor,
maxLines = 1
) )
} }
} }
@@ -508,6 +428,8 @@ fun ChatsListScreen(
} }
} }
Spacer(modifier = Modifier.weight(1f))
// Logout button at bottom // Logout button at bottom
Divider( Divider(
modifier = Modifier.padding(horizontal = 16.dp), modifier = Modifier.padding(horizontal = 16.dp),
@@ -545,6 +467,7 @@ fun ChatsListScreen(
) )
} }
Spacer(modifier = Modifier.navigationBarsPadding())
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
} }
} }