feat: Enhance UnlockScreen with account selection dropdown and search functionality
This commit is contained in:
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,29 +328,12 @@ 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,
|
||||||
@@ -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,46 +375,25 @@ fun ChatsListScreen(
|
|||||||
|
|
||||||
// Theme toggle
|
// Theme toggle
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = {},
|
onClick = onToggleTheme
|
||||||
modifier = Modifier.onGloballyPositioned { coordinates ->
|
|
||||||
// This will be handled by clickable below
|
|
||||||
}
|
|
||||||
) {
|
) {
|
||||||
Box(
|
Icon(
|
||||||
modifier = Modifier
|
if (isDarkTheme) Icons.Default.LightMode else Icons.Default.DarkMode,
|
||||||
.clickable {
|
contentDescription = "Toggle theme",
|
||||||
if (!isTransitioning) {
|
tint = textColor
|
||||||
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(
|
|
||||||
if (isDarkTheme) Icons.Default.LightMode else Icons.Default.DarkMode,
|
|
||||||
contentDescription = "Toggle theme",
|
|
||||||
tint = textColor
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user