feat: Revamp UnlockScreen layout and enhance user experience with improved avatar display and dropdown account selection

This commit is contained in:
k1ngsterr1
2026-01-26 19:53:33 +05:00
parent 6b232006b0
commit 91eb8a4b63

View File

@@ -345,312 +345,185 @@ fun UnlockScreen(
Modifier.fillMaxSize() Modifier.fillMaxSize()
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.imePadding() .imePadding()
.padding(horizontal = 24.dp) .padding(horizontal = 32.dp)
.statusBarsPadding(), .statusBarsPadding(),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Spacer(modifier = Modifier.height(40.dp)) Spacer(modifier = Modifier.height(60.dp))
// Rosetta Logo // 🔥 Большой аватар пользователя - главный элемент (как в desktop)
AnimatedVisibility( AnimatedVisibility(
visible = visible, visible = visible,
enter = fadeIn(tween(300)) + scaleIn(tween(300, easing = FastOutSlowInEasing)) enter = fadeIn(tween(400)) + scaleIn(tween(400, easing = FastOutSlowInEasing))
) { ) {
Image( Box(
painter = painterResource(id = R.drawable.rosetta_icon), modifier = Modifier
contentDescription = "Rosetta", .size(120.dp)
modifier = Modifier.size(100.dp).clip(CircleShape) .clip(RoundedCornerShape(28.dp))
) .background(
if (selectedAccount != null) {
val colors = getAvatarColor(selectedAccount!!.publicKey, isDarkTheme)
colors.backgroundColor
} else {
cardBackground
}
),
contentAlignment = Alignment.Center
) {
if (selectedAccount != null) {
val database = RosettaDatabase.getDatabase(context)
val avatarRepository = remember(selectedAccount!!.publicKey) {
AvatarRepository(context, database.avatarDao(), selectedAccount!!.publicKey)
}
AvatarImage(
publicKey = selectedAccount!!.publicKey,
avatarRepository = avatarRepository,
size = 120.dp,
isDarkTheme = isDarkTheme
)
} else {
Text(
text = "?",
fontSize = 48.sp,
fontWeight = FontWeight.Bold,
color = Color.White
)
}
}
} }
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(20.dp))
// Title // 🔥 Имя пользователя с иконкой переключения (как в desktop)
AnimatedVisibility( AnimatedVisibility(
visible = visible, visible = visible,
enter = fadeIn(tween(400, delayMillis = 200)) enter = fadeIn(tween(400, delayMillis = 150))
) { ) {
Text( Row(
text = "Welcome Back", modifier = Modifier
fontSize = 28.sp, .clip(RoundedCornerShape(8.dp))
fontWeight = FontWeight.Bold, .then(
color = textColor if (accounts.size > 1)
) Modifier.clickable { isDropdownExpanded = !isDropdownExpanded }
else Modifier
)
.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Text(
text = selectedAccount?.name ?: "Select Account",
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
color = textColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
// Иконка переключения аккаунта (только если > 1 аккаунта)
if (accounts.size > 1) {
Spacer(modifier = Modifier.width(4.dp))
Icon(
imageVector = TablerIcons.ChevronDown,
contentDescription = "Switch account",
tint = PrimaryBlue,
modifier = Modifier.size(20.dp)
)
}
}
} }
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
AnimatedVisibility(visible = visible, enter = fadeIn(tween(600, delayMillis = 300))) { // Подзаголовок
AnimatedVisibility(visible = visible, enter = fadeIn(tween(400, delayMillis = 200))) {
Text( Text(
text = "Select your account and enter password", text = "Enter password to unlock",
fontSize = 16.sp, fontSize = 15.sp,
color = secondaryTextColor, color = secondaryTextColor,
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
} }
Spacer(modifier = Modifier.height(32.dp)) Spacer(modifier = Modifier.height(40.dp))
// Account Selector Card // 🔥 Dropdown для выбора аккаунта (показывается только при клике на иконку)
AnimatedVisibility( AnimatedVisibility(
visible = visible, visible = isDropdownExpanded && accounts.size > 1,
enter = fadeIn(tween(400, delayMillis = 350)) enter = fadeIn(tween(200)) + expandVertically(expandFrom = Alignment.Top),
exit = fadeOut(tween(150)) + shrinkVertically(shrinkTowards = Alignment.Top)
) { ) {
Column { Card(
// Account selector dropdown modifier = Modifier
Card( .fillMaxWidth()
modifier = .padding(bottom = 20.dp)
Modifier.fillMaxWidth() .heightIn(max = if (accounts.size > 5) 300.dp else ((accounts.size * 64 + 16).dp)),
.clip(RoundedCornerShape(16.dp)) colors = CardDefaults.cardColors(containerColor = cardBackground),
.clickable(enabled = accounts.size > 1) { shape = RoundedCornerShape(16.dp)
isDropdownExpanded = !isDropdownExpanded ) {
}, LazyColumn(
colors = CardDefaults.cardColors(containerColor = cardBackground), modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)
shape = RoundedCornerShape(16.dp)
) { ) {
Row( items(accounts, key = { it.publicKey }) { account ->
modifier = Modifier.fillMaxWidth().padding(16.dp), val isSelected = account.publicKey == selectedAccount?.publicKey
verticalAlignment = Alignment.CenterVertically
) { Row(
// Avatar modifier = Modifier
if (selectedAccount != null) { .fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.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
) {
val database = RosettaDatabase.getDatabase(context) val database = RosettaDatabase.getDatabase(context)
val avatarRepository = remember(selectedAccount!!.publicKey) { val avatarRepository = remember(account.publicKey) {
AvatarRepository(context, database.avatarDao(), selectedAccount!!.publicKey) AvatarRepository(context, database.avatarDao(), account.publicKey)
} }
AvatarImage( AvatarImage(
publicKey = selectedAccount!!.publicKey, publicKey = account.publicKey,
avatarRepository = avatarRepository, avatarRepository = avatarRepository,
size = 48.dp, size = 40.dp,
isDarkTheme = isDarkTheme isDarkTheme = isDarkTheme
) )
}
Spacer(modifier = Modifier.width(12.dp)) Spacer(modifier = Modifier.width(12.dp))
// Account info Column(modifier = Modifier.weight(1f)) {
Column(modifier = Modifier.weight(1f)) { Text(
Text( text = account.name,
text = selectedAccount?.name ?: "Select Account", fontSize = 15.sp,
fontSize = 16.sp, fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal,
fontWeight = FontWeight.SemiBold, color = textColor,
color = textColor, maxLines = 1,
maxLines = 1, overflow = TextOverflow.Ellipsis
overflow = TextOverflow.Ellipsis )
) val displayText = account.encryptedAccount.username
if (selectedAccount != null) { ?: account.publicKey.take(16) + "..."
val displayText = selectedAccount!!.encryptedAccount.username
?: selectedAccount!!.publicKey.take(20) + "..."
Text( Text(
text = displayText, text = displayText,
fontSize = 13.sp, fontSize = 12.sp,
color = secondaryTextColor, color = secondaryTextColor,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
} }
}
// Dropdown arrow with rotation (only show if multiple accounts) if (isSelected) {
if (accounts.size > 1) { Icon(
Icon( TablerIcons.Check,
imageVector = TablerIcons.ChevronDown, contentDescription = null,
contentDescription = null, tint = PrimaryBlue,
tint = secondaryTextColor.copy(alpha = 0.6f), modifier = Modifier.size(20.dp)
modifier =
Modifier.size(24.dp).graphicsLayer {
rotationZ = 180f * dropdownProgress
}
)
}
}
}
// Dropdown list with animation
AnimatedVisibility(
visible = isDropdownExpanded && accounts.size > 1,
enter =
fadeIn(tween(150)) +
expandVertically(
expandFrom = Alignment.Top,
animationSpec =
tween(200, easing = FastOutSlowInEasing)
),
exit =
fadeOut(tween(100)) +
shrinkVertically(
shrinkTowards = Alignment.Top,
animationSpec = tween(150)
)
) {
Card(
modifier =
Modifier.fillMaxWidth()
.padding(top = 8.dp)
.heightIn(
max =
if (accounts.size > 5) 350.dp
else ((accounts.size * 64 + 70).dp)
),
colors = CardDefaults.cardColors(containerColor = cardBackground),
shape = RoundedCornerShape(16.dp)
) {
Column {
// Search field - only show if more than 3 accounts
if (accounts.size > 3) {
OutlinedTextField(
value = searchQuery,
onValueChange = { searchQuery = it },
placeholder = {
Text(
"Search accounts...",
color =
secondaryTextColor.copy(
alpha = 0.6f
)
)
},
leadingIcon = {
Icon(
TablerIcons.Search,
contentDescription = null,
tint = secondaryTextColor.copy(alpha = 0.6f)
)
},
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()
.padding(
vertical =
if (accounts.size <= 3) 8.dp
else 0.dp
)
) {
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)
.clip(RoundedCornerShape(12.dp))
.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 database = RosettaDatabase.getDatabase(context)
val avatarRepository = remember(account.publicKey) {
AvatarRepository(context, database.avatarDao(), account.publicKey)
}
AvatarImage(
publicKey = account.publicKey,
avatarRepository = avatarRepository,
size = 40.dp,
isDarkTheme = isDarkTheme
)
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
)
val displayText = account.encryptedAccount.username
?: account.publicKey.take(16) + "..."
Text(
text = displayText,
fontSize = 12.sp,
color = secondaryTextColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
if (isSelected) {
Icon(
TablerIcons.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
)
}
}
} }
} }
} }
@@ -658,12 +531,10 @@ fun UnlockScreen(
} }
} }
Spacer(modifier = Modifier.height(20.dp)) // 🔥 Минималистичное поле пароля
// Password Field
AnimatedVisibility( AnimatedVisibility(
visible = visible && !isDropdownExpanded, visible = visible && !isDropdownExpanded,
enter = fadeIn(tween(400, delayMillis = 400)) enter = fadeIn(tween(400, delayMillis = 300))
) { ) {
OutlinedTextField( OutlinedTextField(
value = password, value = password,
@@ -671,8 +542,12 @@ fun UnlockScreen(
password = it password = it
error = null error = null
}, },
label = { Text("Password") }, placeholder = {
placeholder = { Text("Enter your password") }, Text(
"Password",
color = secondaryTextColor.copy(alpha = 0.6f)
)
},
singleLine = true, singleLine = true,
visualTransformation = visualTransformation =
if (passwordVisible) VisualTransformation.None if (passwordVisible) VisualTransformation.None
@@ -683,30 +558,29 @@ fun UnlockScreen(
imageVector = imageVector =
if (passwordVisible) TablerIcons.EyeOff if (passwordVisible) TablerIcons.EyeOff
else TablerIcons.Eye, else TablerIcons.Eye,
contentDescription = if (passwordVisible) "Hide" else "Show" contentDescription = if (passwordVisible) "Hide" else "Show",
tint = secondaryTextColor
) )
} }
}, },
isError = error != null, isError = error != null,
colors = colors = OutlinedTextFieldDefaults.colors(
OutlinedTextFieldDefaults.colors( focusedBorderColor = PrimaryBlue,
focusedBorderColor = PrimaryBlue, unfocusedBorderColor = Color.Transparent,
unfocusedBorderColor = focusedContainerColor = cardBackground,
if (isDarkTheme) Color(0xFF4A4A4A) unfocusedContainerColor = cardBackground,
else Color(0xFFD0D0D0), focusedTextColor = textColor,
focusedLabelColor = PrimaryBlue, unfocusedTextColor = textColor,
cursorColor = PrimaryBlue, cursorColor = PrimaryBlue,
focusedTextColor = textColor, errorBorderColor = Color(0xFFE53935),
unfocusedTextColor = textColor, errorContainerColor = cardBackground
errorBorderColor = Color(0xFFE53935) ),
),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(14.dp),
keyboardOptions = keyboardOptions = KeyboardOptions(
KeyboardOptions( keyboardType = KeyboardType.Password,
keyboardType = KeyboardType.Password, imeAction = ImeAction.Done
imeAction = ImeAction.Done )
)
) )
} }
@@ -722,136 +596,159 @@ fun UnlockScreen(
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
// Unlock Button // 🔥 Градиентная кнопка Unlock
AnimatedVisibility(
visible = visible && !isDropdownExpanded,
enter = fadeIn(tween(400, delayMillis = 400))
) {
val canUseBiometric = remember(selectedAccount, isBiometricEnabled, biometricAvailable) {
selectedAccount != null &&
isBiometricEnabled &&
biometricAvailable is BiometricAvailability.Available &&
activity != null
}
val hasSavedPassword = remember(selectedAccount, savedPasswordsMap) {
selectedAccount?.let { savedPasswordsMap.containsKey(it.publicKey) } ?: false
}
val showBiometricButton = canUseBiometric && hasSavedPassword
Column(horizontalAlignment = Alignment.CenterHorizontally) {
// Основная кнопка Unlock
Button(
onClick = {
if (selectedAccount == null) {
error = "Please select an account"
return@Button
}
if (password.isEmpty()) {
error = "Please enter your password"
return@Button
}
scope.launch {
performUnlock(
selectedAccount = selectedAccount,
password = password,
accountManager = accountManager,
onUnlocking = { isUnlocking = it },
onError = { error = it },
onSuccess = { decryptedAccount ->
onUnlocked(decryptedAccount)
}
)
}
},
enabled = selectedAccount != null && password.isNotEmpty() && !isUnlocking,
modifier = Modifier
.fillMaxWidth()
.height(54.dp),
colors = ButtonDefaults.buttonColors(
containerColor = PrimaryBlue,
contentColor = Color.White,
disabledContainerColor = PrimaryBlue.copy(alpha = 0.4f),
disabledContentColor = Color.White.copy(alpha = 0.6f)
),
shape = RoundedCornerShape(14.dp)
) {
if (isUnlocking) {
CircularProgressIndicator(
modifier = Modifier.size(22.dp),
color = Color.White,
strokeWidth = 2.dp
)
} else {
Icon(
imageVector = TablerIcons.LockOpen,
contentDescription = null,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(10.dp))
Text(text = "Unlock", fontSize = 16.sp, fontWeight = FontWeight.SemiBold)
}
}
// Кнопка биометрии (если доступна)
if (showBiometricButton) {
Spacer(modifier = Modifier.height(16.dp))
TextButton(
onClick = { tryBiometricUnlock() },
enabled = !isUnlocking
) {
Icon(
imageVector = TablerIcons.Fingerprint,
contentDescription = null,
tint = PrimaryBlue,
modifier = Modifier.size(22.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Use biometric",
color = PrimaryBlue,
fontSize = 15.sp,
fontWeight = FontWeight.Medium
)
}
}
}
}
Spacer(modifier = Modifier.weight(1f))
// 🔥 Footer - Create/Import account
AnimatedVisibility( AnimatedVisibility(
visible = visible && !isDropdownExpanded, visible = visible && !isDropdownExpanded,
enter = fadeIn(tween(400, delayMillis = 500)) enter = fadeIn(tween(400, delayMillis = 500))
) { ) {
Column { Column(
// Check if biometric unlock is available for selected account horizontalAlignment = Alignment.CenterHorizontally,
val canUseBiometric = remember(selectedAccount, isBiometricEnabled, biometricAvailable) { modifier = Modifier.padding(bottom = 32.dp)
selectedAccount != null && ) {
isBiometricEnabled && // Разделитель
biometricAvailable is BiometricAvailability.Available &&
activity != null
}
val hasSavedPassword = remember(selectedAccount, savedPasswordsMap) {
selectedAccount?.let { savedPasswordsMap.containsKey(it.publicKey) } ?: false
}
val showBiometricButton = canUseBiometric && hasSavedPassword
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp) verticalAlignment = Alignment.CenterVertically
) { ) {
// Password Unlock Button Box(
Button( modifier = Modifier
onClick = { .weight(1f)
if (selectedAccount == null) { .height(0.5.dp)
error = "Please select an account" .background(secondaryTextColor.copy(alpha = 0.3f))
return@Button )
} Text(
if (password.isEmpty()) { text = " or ",
error = "Please enter your password" color = secondaryTextColor,
return@Button fontSize = 13.sp
} )
Box(
scope.launch { modifier = Modifier
performUnlock( .weight(1f)
selectedAccount = selectedAccount, .height(0.5.dp)
password = password, .background(secondaryTextColor.copy(alpha = 0.3f))
accountManager = accountManager, )
onUnlocking = { isUnlocking = it }, }
onError = { error = it },
onSuccess = { decryptedAccount -> Spacer(modifier = Modifier.height(20.dp))
onUnlocked(decryptedAccount)
} TextButton(onClick = onSwitchAccount) {
) Icon(
} imageVector = TablerIcons.UserPlus,
}, contentDescription = null,
enabled = selectedAccount != null && password.isNotEmpty() && !isUnlocking, tint = PrimaryBlue,
modifier = Modifier modifier = Modifier.size(18.dp)
.weight(1f) )
.height(56.dp), Spacer(modifier = Modifier.width(8.dp))
colors = Text(
ButtonDefaults.buttonColors( text = "Create or import account",
containerColor = PrimaryBlue, color = PrimaryBlue,
contentColor = Color.White, fontSize = 15.sp,
disabledContainerColor = PrimaryBlue.copy(alpha = 0.5f), fontWeight = FontWeight.Medium
disabledContentColor = Color.White.copy(alpha = 0.5f) )
),
shape = RoundedCornerShape(12.dp)
) {
if (isUnlocking) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = Color.White,
strokeWidth = 2.dp
)
} else {
Icon(
imageVector = TablerIcons.LockOpen,
contentDescription = null,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(text = "Unlock", fontSize = 16.sp, fontWeight = FontWeight.SemiBold)
}
}
// Biometric Unlock Button
if (showBiometricButton) {
Button(
onClick = {
tryBiometricUnlock()
},
enabled = !isUnlocking,
modifier = Modifier
.width(56.dp)
.height(56.dp),
colors =
ButtonDefaults.buttonColors(
containerColor = PrimaryBlue,
contentColor = Color.White,
disabledContainerColor = PrimaryBlue.copy(alpha = 0.5f),
disabledContentColor = Color.White.copy(alpha = 0.5f)
),
shape = RoundedCornerShape(12.dp),
contentPadding = PaddingValues(0.dp)
) {
Icon(
imageVector = TablerIcons.Fingerprint,
contentDescription = "Unlock with biometric",
modifier = Modifier.size(28.dp)
)
}
}
} }
} }
} }
Spacer(modifier = Modifier.height(16.dp))
// Create New Account button
AnimatedVisibility(
visible = visible && !isDropdownExpanded,
enter = fadeIn(tween(400, delayMillis = 600))
) {
TextButton(onClick = onSwitchAccount) {
Icon(
imageVector = TablerIcons.UserPlus,
contentDescription = null,
tint = PrimaryBlue,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(text = "Create New Account", color = PrimaryBlue, fontSize = 15.sp)
}
}
Spacer(modifier = Modifier.height(60.dp))
} }
} }
} }