Онбординг: отдельный экран биометрии, новый UI пароля (Telegram-style), Skip на всех шагах. Биометрия per-account. Навбар плавно анимируется при смене темы. Поиск: аватарки в результатах. Профиль: клавиатура прячется при скролле. Фокус сбрасывается при навигации.

This commit is contained in:
2026-04-08 02:56:53 +05:00
parent 14d7fc6eb1
commit 299c84cb89
11 changed files with 649 additions and 651 deletions

View File

@@ -1037,6 +1037,12 @@ fun MainScreen(
// Anti-spam: do not stack duplicate screens from rapid taps.
if (navStack.lastOrNull() == screen) return
if (screen is Screen.Requests && navStack.any { it is Screen.Requests }) return
// Hide keyboard and clear focus when navigating to any screen
(context as? android.app.Activity)?.currentFocus?.let { focusedView ->
val imm = context.getSystemService(android.content.Context.INPUT_METHOD_SERVICE) as android.view.inputmethod.InputMethodManager
imm.hideSoftInputFromWindow(focusedView.windowToken, 0)
focusedView.clearFocus()
}
navStack = navStack + screen
}
fun isCurrentAccountUser(user: SearchUser): Boolean {
@@ -1768,7 +1774,7 @@ fun MainScreen(
accountPublicKey,
encryptedPassword
)
biometricPrefs.enableBiometric()
biometricPrefs.enableBiometric(accountPublicKey)
onSuccess()
}
},

View File

@@ -14,50 +14,36 @@ import kotlinx.coroutines.withContext
/**
* Безопасное хранилище настроек биометрической аутентификации
* Использует EncryptedSharedPreferences с MasterKey из Android Keystore
*
* Уровни защиты:
* - AES256_GCM для шифрования значений
* - AES256_SIV для шифрования ключей
* - MasterKey хранится в Android Keystore (TEE/StrongBox)
*
* Биометрия привязана к конкретному аккаунту (per-account), не глобальная.
*/
class BiometricPreferences(private val context: Context) {
companion object {
private const val TAG = "BiometricPreferences"
private const val PREFS_FILE_NAME = "rosetta_secure_biometric_prefs"
private const val KEY_BIOMETRIC_ENABLED = "biometric_enabled"
private const val KEY_BIOMETRIC_ENABLED_PREFIX = "biometric_enabled_"
private const val ENCRYPTED_PASSWORD_PREFIX = "encrypted_password_"
// Shared between all BiometricPreferences instances so UI in different screens
// receives updates immediately (ProfileScreen <-> BiometricEnableScreen).
// Legacy key (global) — for migration
private const val KEY_BIOMETRIC_ENABLED_LEGACY = "biometric_enabled"
// Shared state for reactive UI updates
private val biometricEnabledState = MutableStateFlow(false)
}
private val appContext = context.applicationContext
private val _isBiometricEnabled = biometricEnabledState
private val encryptedPrefs: SharedPreferences by lazy {
createEncryptedPreferences()
}
init {
// Загружаем начальное значение
try {
_isBiometricEnabled.value = encryptedPrefs.getBoolean(KEY_BIOMETRIC_ENABLED, false)
} catch (e: Exception) {
}
}
/**
* Создает EncryptedSharedPreferences с максимальной защитой
*/
private fun createEncryptedPreferences(): SharedPreferences {
try {
// Создаем MasterKey с максимальной защитой
val masterKey = MasterKey.Builder(appContext)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.setUserAuthenticationRequired(false) // Биометрия проверяется отдельно в BiometricAuthManager
.setUserAuthenticationRequired(false)
.build()
return EncryptedSharedPreferences.create(
appContext,
PREFS_FILE_NAME,
@@ -66,77 +52,93 @@ class BiometricPreferences(private val context: Context) {
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
} catch (e: Exception) {
// Fallback на обычные SharedPreferences в случае ошибки (не должно произойти)
return appContext.getSharedPreferences(PREFS_FILE_NAME + "_fallback", Context.MODE_PRIVATE)
}
}
/**
* Включена ли биометрическая аутентификация
*/
val isBiometricEnabled: Flow<Boolean> = _isBiometricEnabled.asStateFlow()
/**
* Включить биометрическую аутентификацию
* Загрузить состояние биометрии для конкретного аккаунта
*/
fun loadForAccount(publicKey: String) {
try {
val key = "$KEY_BIOMETRIC_ENABLED_PREFIX$publicKey"
val perAccount = encryptedPrefs.getBoolean(key, false)
// Migration: если per-account нет, проверяем legacy глобальный ключ
if (!perAccount && encryptedPrefs.getBoolean(KEY_BIOMETRIC_ENABLED_LEGACY, false)) {
// Мигрируем: копируем глобальное значение в per-account
encryptedPrefs.edit().putBoolean(key, true).apply()
_isBiometricEnabled.value = true
} else {
_isBiometricEnabled.value = perAccount
}
} catch (e: Exception) {
_isBiometricEnabled.value = false
}
}
/**
* Включить биометрическую аутентификацию для аккаунта
*/
suspend fun enableBiometric(publicKey: String) = withContext(Dispatchers.IO) {
val key = "$KEY_BIOMETRIC_ENABLED_PREFIX$publicKey"
encryptedPrefs.edit().putBoolean(key, true).commit()
_isBiometricEnabled.value = true
}
/**
* Отключить биометрическую аутентификацию для аккаунта
*/
suspend fun disableBiometric(publicKey: String) = withContext(Dispatchers.IO) {
val key = "$KEY_BIOMETRIC_ENABLED_PREFIX$publicKey"
encryptedPrefs.edit().putBoolean(key, false).commit()
_isBiometricEnabled.value = false
}
/**
* Проверить включена ли биометрия для аккаунта (синхронно)
*/
fun isBiometricEnabledForAccount(publicKey: String): Boolean {
return try {
encryptedPrefs.getBoolean("$KEY_BIOMETRIC_ENABLED_PREFIX$publicKey", false)
} catch (_: Exception) { false }
}
// --- Legacy compat: old callers without publicKey ---
@Deprecated("Use enableBiometric(publicKey) instead")
suspend fun enableBiometric() = withContext(Dispatchers.IO) {
val success = encryptedPrefs.edit().putBoolean(KEY_BIOMETRIC_ENABLED, true).commit()
if (!success) {
Log.w(TAG, "Failed to persist biometric enabled state")
}
_isBiometricEnabled.value = encryptedPrefs.getBoolean(KEY_BIOMETRIC_ENABLED, false)
encryptedPrefs.edit().putBoolean(KEY_BIOMETRIC_ENABLED_LEGACY, true).commit()
_isBiometricEnabled.value = true
}
/**
* Отключить биометрическую аутентификацию
*/
@Deprecated("Use disableBiometric(publicKey) instead")
suspend fun disableBiometric() = withContext(Dispatchers.IO) {
val success = encryptedPrefs.edit().putBoolean(KEY_BIOMETRIC_ENABLED, false).commit()
if (!success) {
Log.w(TAG, "Failed to persist biometric disabled state")
}
_isBiometricEnabled.value = encryptedPrefs.getBoolean(KEY_BIOMETRIC_ENABLED, false)
encryptedPrefs.edit().putBoolean(KEY_BIOMETRIC_ENABLED_LEGACY, false).commit()
_isBiometricEnabled.value = false
}
/**
* Сохранить зашифрованный пароль для аккаунта
* Пароль уже зашифрован BiometricAuthManager, здесь добавляется второй слой шифрования
*/
suspend fun saveEncryptedPassword(publicKey: String, encryptedPassword: String) = withContext(Dispatchers.IO) {
val key = "$ENCRYPTED_PASSWORD_PREFIX$publicKey"
encryptedPrefs.edit().putString(key, encryptedPassword).apply()
}
/**
* Получить зашифрованный пароль для аккаунта
*/
suspend fun getEncryptedPassword(publicKey: String): String? = withContext(Dispatchers.IO) {
val key = "$ENCRYPTED_PASSWORD_PREFIX$publicKey"
encryptedPrefs.getString(key, null)
}
/**
* Удалить зашифрованный пароль для аккаунта
*/
suspend fun removeEncryptedPassword(publicKey: String) = withContext(Dispatchers.IO) {
val key = "$ENCRYPTED_PASSWORD_PREFIX$publicKey"
encryptedPrefs.edit().remove(key).apply()
}
/**
* Удалить все биометрические данные
*/
suspend fun clearAll() = withContext(Dispatchers.IO) {
val success = encryptedPrefs.edit().clear().commit()
if (!success) {
Log.w(TAG, "Failed to clear biometric preferences")
}
_isBiometricEnabled.value = encryptedPrefs.getBoolean(KEY_BIOMETRIC_ENABLED, false)
encryptedPrefs.edit().clear().commit()
_isBiometricEnabled.value = false
}
/**
* Проверить, есть ли сохраненный зашифрованный пароль для аккаунта
*/
suspend fun hasEncryptedPassword(publicKey: String): Boolean {
return getEncryptedPassword(publicKey) != null
}

View File

@@ -15,6 +15,7 @@ enum class AuthScreen {
SEED_PHRASE,
CONFIRM_SEED,
SET_PASSWORD,
SET_BIOMETRIC,
SET_PROFILE,
IMPORT_SEED,
UNLOCK
@@ -87,8 +88,10 @@ fun AuthFlow(
currentScreen = AuthScreen.SEED_PHRASE
}
}
AuthScreen.SET_BIOMETRIC -> {
currentScreen = AuthScreen.SET_PROFILE
}
AuthScreen.SET_PROFILE -> {
// Skip profile setup — complete auth
onAuthComplete(createdAccount)
}
AuthScreen.IMPORT_SEED -> {
@@ -180,11 +183,19 @@ fun AuthFlow(
onAuthComplete(account)
} else {
createdAccount = account
currentScreen = AuthScreen.SET_PROFILE
currentScreen = AuthScreen.SET_BIOMETRIC
}
}
)
}
AuthScreen.SET_BIOMETRIC -> {
SetBiometricScreen(
isDarkTheme = isDarkTheme,
account = createdAccount,
onContinue = { currentScreen = AuthScreen.SET_PROFILE }
)
}
AuthScreen.SET_PROFILE -> {
SetProfileScreen(

View File

@@ -0,0 +1,254 @@
package com.rosetta.messenger.ui.auth
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import compose.icons.TablerIcons
import compose.icons.tablericons.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rosetta.messenger.biometric.BiometricAuthManager
import com.rosetta.messenger.biometric.BiometricAvailability
import com.rosetta.messenger.biometric.BiometricPreferences
import com.rosetta.messenger.crypto.CryptoManager
import com.rosetta.messenger.data.DecryptedAccount
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import kotlinx.coroutines.launch
private val PrimaryBlue = Color(0xFF228BE6)
private val PrimaryBlueDark = Color(0xFF5AA5FF)
@Composable
fun SetBiometricScreen(
isDarkTheme: Boolean,
account: DecryptedAccount?,
onContinue: () -> Unit
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val biometricManager = remember { BiometricAuthManager(context) }
val biometricPrefs = remember { BiometricPreferences(context) }
val biometricAvailable = remember { biometricManager.isBiometricAvailable() is BiometricAvailability.Available }
var biometricEnabled by remember { mutableStateOf(false) }
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF8F8FF)
val textColor = if (isDarkTheme) Color.White else Color.Black
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF8E8E93)
val cardColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFF2F2F7)
val accentColor = if (isDarkTheme) PrimaryBlueDark else PrimaryBlue
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as android.app.Activity).window
val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view)
insetsController.isAppearanceLightStatusBars = false
window.statusBarColor = android.graphics.Color.TRANSPARENT
}
}
Box(
modifier = Modifier
.fillMaxSize()
.background(backgroundColor)
.statusBarsPadding()
.navigationBarsPadding()
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Skip button
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
TextButton(onClick = onContinue) {
Text(
text = "Skip",
color = accentColor,
fontSize = 16.sp,
fontWeight = FontWeight.Medium
)
}
}
Spacer(modifier = Modifier.height(24.dp))
// Lock illustration
Box(
modifier = Modifier.size(120.dp),
contentAlignment = Alignment.Center
) {
// Background circle
Box(
modifier = Modifier
.size(100.dp)
.clip(CircleShape)
.background(accentColor.copy(alpha = 0.15f))
)
// Lock icon
Icon(
imageVector = TablerIcons.ShieldLock,
contentDescription = null,
tint = accentColor,
modifier = Modifier.size(56.dp)
)
}
Spacer(modifier = Modifier.height(28.dp))
Text(
text = "Protect Your Account",
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
color = textColor,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(10.dp))
Text(
text = "Adding biometric protection ensures\nthat only you can access your account.",
fontSize = 15.sp,
color = secondaryTextColor,
textAlign = TextAlign.Center,
lineHeight = 22.sp
)
Spacer(modifier = Modifier.height(36.dp))
// Biometric toggle card
if (biometricAvailable) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(14.dp))
.background(cardColor)
.clickable { biometricEnabled = !biometricEnabled }
.padding(horizontal = 18.dp, vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = TablerIcons.Fingerprint,
contentDescription = null,
tint = accentColor,
modifier = Modifier.size(28.dp)
)
Spacer(modifier = Modifier.width(14.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Biometrics",
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = textColor
)
Text(
text = "Use biometric authentication to unlock",
fontSize = 13.sp,
color = secondaryTextColor,
maxLines = 1
)
}
Spacer(modifier = Modifier.width(8.dp))
Switch(
checked = biometricEnabled,
onCheckedChange = { biometricEnabled = it },
colors = SwitchDefaults.colors(
checkedThumbColor = Color.White,
checkedTrackColor = accentColor,
uncheckedThumbColor = Color.White,
uncheckedTrackColor = secondaryTextColor.copy(alpha = 0.3f)
)
)
}
} else {
// Device doesn't support biometrics
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(14.dp))
.background(cardColor)
.padding(horizontal = 18.dp, vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = TablerIcons.Fingerprint,
contentDescription = null,
tint = secondaryTextColor,
modifier = Modifier.size(28.dp)
)
Spacer(modifier = Modifier.width(14.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Biometrics",
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = secondaryTextColor
)
Text(
text = "Not available on this device",
fontSize = 13.sp,
color = secondaryTextColor.copy(alpha = 0.7f)
)
}
}
}
Spacer(modifier = Modifier.weight(1f))
// Continue button
Button(
onClick = {
scope.launch {
if (biometricEnabled && account != null) {
try {
biometricPrefs.enableBiometric(account.publicKey)
// Save encrypted password for biometric unlock
biometricPrefs.saveEncryptedPassword(
account.publicKey,
CryptoManager.encryptWithPassword(
account.privateKey.take(16),
account.publicKey
)
)
} catch (_: Exception) {}
}
onContinue()
}
},
modifier = Modifier
.fillMaxWidth()
.height(52.dp),
colors = ButtonDefaults.buttonColors(
containerColor = accentColor,
contentColor = Color.White
),
shape = RoundedCornerShape(14.dp)
) {
Text(
text = "Continue",
fontSize = 17.sp,
fontWeight = FontWeight.SemiBold
)
}
Spacer(modifier = Modifier.height(32.dp))
}
}
}

View File

@@ -25,9 +25,6 @@ import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rosetta.messenger.biometric.BiometricAuthManager
import com.rosetta.messenger.biometric.BiometricAvailability
import com.rosetta.messenger.biometric.BiometricPreferences
import com.rosetta.messenger.crypto.CryptoManager
import com.rosetta.messenger.data.AccountManager
import com.rosetta.messenger.data.DecryptedAccount
@@ -44,28 +41,10 @@ fun SetPasswordScreen(
onBack: () -> Unit,
onAccountCreated: (DecryptedAccount) -> Unit
) {
val themeAnimSpec =
tween<Color>(durationMillis = 500, easing = CubicBezierEasing(0.4f, 0f, 0.2f, 1f))
val backgroundColor by
animateColorAsState(
if (isDarkTheme) AuthBackground else AuthBackgroundLight,
animationSpec = themeAnimSpec
)
val 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 cardColor by
animateColorAsState(
if (isDarkTheme) AuthSurface else AuthSurfaceLight,
animationSpec = themeAnimSpec
)
val backgroundColor = if (isDarkTheme) AuthBackground else AuthBackgroundLight
val textColor = if (isDarkTheme) Color.White else Color.Black
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF8E8E93)
val fieldBackground = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFF2F2F7)
val context = LocalContext.current
val accountManager = remember { AccountManager(context) }
@@ -77,565 +56,300 @@ fun SetPasswordScreen(
var confirmPasswordVisible by remember { mutableStateOf(false) }
var isCreating by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
var visible by remember { mutableStateOf(false) }
val biometricManager = remember { BiometricAuthManager(context) }
val biometricPrefs = remember { BiometricPreferences(context) }
val biometricAvailable = remember { biometricManager.isBiometricAvailable() is BiometricAvailability.Available }
var biometricEnabled by remember { mutableStateOf(biometricAvailable) }
// Track keyboard visibility
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as android.app.Activity).window
val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view)
// Auth screens should always keep white status bar icons.
insetsController.isAppearanceLightStatusBars = false
window.statusBarColor = android.graphics.Color.TRANSPARENT
}
}
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 }
val passwordsMatch = password == confirmPassword && password.isNotEmpty()
val isPasswordWeak = password.isNotEmpty() && password.length < 6
val canContinue = passwordsMatch && !isCreating
Box(modifier = Modifier.fillMaxSize().background(backgroundColor).navigationBarsPadding()) {
Column(modifier = Modifier.fillMaxSize().statusBarsPadding()) {
// Top Bar
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = onBack, enabled = !isCreating) {
Icon(
Scaffold(
containerColor = backgroundColor,
topBar = {
TopAppBar(
title = {},
navigationIcon = {
IconButton(onClick = onBack, enabled = !isCreating) {
Icon(
imageVector = TablerIcons.ChevronLeft,
contentDescription = "Back",
tint = textColor.copy(alpha = 0.6f)
)
}
Spacer(modifier = Modifier.weight(1f))
Text(
text = "Set Password",
fontSize = 18.sp,
fontWeight = FontWeight.SemiBold,
color = textColor
tint = textColor
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent
)
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.imePadding()
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(12.dp))
// Lock icon
Box(
modifier = Modifier
.size(56.dp)
.clip(RoundedCornerShape(16.dp))
.background(PrimaryBlue.copy(alpha = 0.1f)),
contentAlignment = Alignment.Center
) {
Icon(
TablerIcons.Lock,
contentDescription = null,
tint = PrimaryBlue,
modifier = Modifier.size(28.dp)
)
Spacer(modifier = Modifier.weight(1f))
Spacer(modifier = Modifier.width(48.dp))
}
Column(
modifier =
Modifier.fillMaxSize()
.imePadding()
.padding(horizontal = 24.dp)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(if (isKeyboardVisible) 8.dp else 16.dp))
Spacer(modifier = Modifier.height(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)
)
Text(
text = if (isImportMode) "Recover Account" else "Protect Your Account",
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
color = textColor
)
AnimatedVisibility(
visible = visible,
enter =
fadeIn(tween(250)) +
scaleIn(
initialScale = 0.5f,
animationSpec =
tween(250, easing = FastOutSlowInEasing)
)
Spacer(modifier = Modifier.height(6.dp))
Text(
text = "This password encrypts your keys locally.\nYou'll need it to unlock Rosetta.",
fontSize = 14.sp,
color = secondaryTextColor,
textAlign = TextAlign.Center,
lineHeight = 20.sp
)
Spacer(modifier = Modifier.height(28.dp))
// Password field — clean Telegram style
TextField(
value = password,
onValueChange = { password = it; error = null },
placeholder = { Text("Password", color = secondaryTextColor) },
singleLine = true,
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
trailingIcon = {
IconButton(onClick = { passwordVisible = !passwordVisible }) {
Icon(
imageVector = if (passwordVisible) TablerIcons.EyeOff else TablerIcons.Eye,
contentDescription = null,
tint = secondaryTextColor,
modifier = Modifier.size(20.dp)
)
}
},
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(12.dp)),
colors = TextFieldDefaults.colors(
focusedTextColor = textColor,
unfocusedTextColor = textColor,
focusedContainerColor = fieldBackground,
unfocusedContainerColor = fieldBackground,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
cursorColor = PrimaryBlue
),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Next
)
)
// Strength bar
if (password.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp))
val strength = when {
password.length < 6 -> 0.25f
password.length < 10 -> 0.6f
else -> 1f
}
val strengthColor = when {
password.length < 6 -> Color(0xFFE53935)
password.length < 10 -> Color(0xFFFFA726)
else -> Color(0xFF4CAF50)
}
val strengthLabel = when {
password.length < 6 -> "Weak"
password.length < 10 -> "Medium"
else -> "Strong"
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Box(
modifier =
Modifier.size(iconSize)
.clip(
RoundedCornerShape(
if (isKeyboardVisible) 12.dp else 20.dp
)
)
.background(PrimaryBlue.copy(alpha = 0.1f)),
contentAlignment = Alignment.Center
modifier = Modifier
.weight(1f)
.height(3.dp)
.clip(RoundedCornerShape(2.dp))
.background(fieldBackground)
) {
Icon(
TablerIcons.Lock,
contentDescription = null,
tint = PrimaryBlue,
modifier = Modifier.size(iconInnerSize)
)
}
}
Spacer(modifier = Modifier.height(if (isKeyboardVisible) 12.dp else 24.dp))
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(400, delayMillis = 100))
) {
Text(
text = if (isImportMode) "Recover Account" else "Protect Your Account",
fontSize = if (isKeyboardVisible) 20.sp else 24.sp,
fontWeight = FontWeight.Bold,
color = textColor
)
}
Spacer(modifier = Modifier.height(if (isKeyboardVisible) 6.dp else 8.dp))
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(500, delayMillis = 200))
) {
Text(
text = if (isImportMode)
"Set a password to protect your recovered account.\nYou'll need it to unlock Rosetta."
else
"This password encrypts your keys locally.\nYou'll need it to unlock Rosetta.",
fontSize = if (isKeyboardVisible) 12.sp else 14.sp,
color = secondaryTextColor,
textAlign = TextAlign.Center,
lineHeight = if (isKeyboardVisible) 16.sp else 20.sp
)
}
Spacer(modifier = Modifier.height(if (isKeyboardVisible) 16.dp else 32.dp))
// Password Field
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(400, delayMillis = 300))
) {
OutlinedTextField(
value = password,
onValueChange = {
password = it
error = null
},
label = { Text("Password") },
placeholder = { Text("Enter password") },
singleLine = true,
visualTransformation =
if (passwordVisible) VisualTransformation.None
else PasswordVisualTransformation(),
trailingIcon = {
IconButton(onClick = { passwordVisible = !passwordVisible }) {
Icon(
imageVector =
if (passwordVisible) TablerIcons.EyeOff
else TablerIcons.Eye,
contentDescription =
if (passwordVisible) "Hide" else "Show"
)
}
},
colors =
OutlinedTextFieldDefaults.colors(
focusedBorderColor = PrimaryBlue,
unfocusedBorderColor =
if (isDarkTheme) Color(0xFF4A4A4A)
else Color(0xFFD0D0D0),
focusedLabelColor = PrimaryBlue,
cursorColor = PrimaryBlue,
focusedTextColor = textColor,
unfocusedTextColor = textColor
),
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
keyboardOptions =
KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Next
)
)
}
// Password strength indicator
if (password.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp))
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(400, delayMillis = 350))
) {
Column(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
val strength =
when {
password.length < 6 -> "Weak"
password.length < 10 -> "Medium"
else -> "Strong"
}
val strengthColor =
when {
password.length < 6 -> Color(0xFFE53935)
password.length < 10 -> Color(0xFFFFA726)
else -> Color(0xFF4CAF50)
}
Icon(
painter = TelegramIcons.Secret,
contentDescription = null,
tint = strengthColor,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "Password strength: $strength",
fontSize = 12.sp,
color = strengthColor
)
}
// Warning for weak passwords
if (isPasswordWeak) {
Spacer(modifier = Modifier.height(4.dp))
Row(
modifier =
Modifier.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(
Color(0xFFE53935).copy(alpha = 0.1f)
)
.padding(8.dp),
verticalAlignment = Alignment.Top
) {
Icon(
painter = TelegramIcons.Warning,
contentDescription = null,
tint = Color(0xFFE53935),
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text =
"Your password is too weak. Consider using at least 6 characters for better security.",
fontSize = 11.sp,
color = Color(0xFFE53935),
lineHeight = 14.sp
)
}
}
}
}
}
Spacer(modifier = Modifier.height(16.dp))
// Confirm Password Field
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(400, delayMillis = 400))
) {
OutlinedTextField(
value = confirmPassword,
onValueChange = {
confirmPassword = it
error = null
},
label = { Text("Confirm Password") },
placeholder = { Text("Re-enter password") },
singleLine = true,
visualTransformation =
if (confirmPasswordVisible) VisualTransformation.None
else PasswordVisualTransformation(),
trailingIcon = {
IconButton(
onClick = {
confirmPasswordVisible = !confirmPasswordVisible
}
) {
Icon(
imageVector =
if (confirmPasswordVisible)
TablerIcons.EyeOff
else TablerIcons.Eye,
contentDescription =
if (confirmPasswordVisible) "Hide" else "Show"
)
}
},
isError = confirmPassword.isNotEmpty() && !passwordsMatch,
colors =
OutlinedTextFieldDefaults.colors(
focusedBorderColor = PrimaryBlue,
unfocusedBorderColor =
if (isDarkTheme) Color(0xFF4A4A4A)
else Color(0xFFD0D0D0),
focusedLabelColor = PrimaryBlue,
cursorColor = PrimaryBlue,
focusedTextColor = textColor,
unfocusedTextColor = textColor
),
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
keyboardOptions =
KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done
)
)
}
// Match indicator
if (confirmPassword.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp))
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(400, delayMillis = 450))
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
val matchIcon =
if (passwordsMatch) TablerIcons.Check else TablerIcons.X
val matchColor =
if (passwordsMatch) Color(0xFF4CAF50) else Color(0xFFE53935)
val matchText =
if (passwordsMatch) "Passwords match"
else "Passwords don't match"
Icon(
imageVector = matchIcon,
contentDescription = null,
tint = matchColor,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text(text = matchText, fontSize = 12.sp, color = matchColor)
}
}
}
// Error message
error?.let { errorMsg ->
Spacer(modifier = Modifier.height(16.dp))
AnimatedVisibility(
visible = true,
enter = fadeIn(tween(300)) + scaleIn(initialScale = 0.9f)
) {
Text(
text = errorMsg,
fontSize = 14.sp,
color = Color(0xFFE53935),
textAlign = TextAlign.Center
)
}
}
Spacer(modifier = Modifier.weight(1f))
// Info - hide when keyboard is visible
AnimatedVisibility(
visible = visible && !isKeyboardVisible,
enter = fadeIn(tween(300)),
exit = fadeOut(tween(200))
) {
Row(
modifier =
Modifier.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(cardColor)
.padding(16.dp),
verticalAlignment = Alignment.Top
) {
Icon(
painter = TelegramIcons.Info,
contentDescription = null,
tint = PrimaryBlue,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text =
"Your password is never stored or sent anywhere. It's only used to encrypt your keys locally.",
fontSize = 13.sp,
color = secondaryTextColor,
lineHeight = 18.sp
)
}
}
// Biometric toggle
if (biometricAvailable && !isKeyboardVisible) {
Spacer(modifier = Modifier.height(12.dp))
Row(
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(cardColor)
.clickable { biometricEnabled = !biometricEnabled }
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = TablerIcons.Fingerprint,
contentDescription = null,
tint = PrimaryBlue,
modifier = Modifier.size(22.dp)
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = "Use Biometrics",
fontSize = 15.sp,
color = textColor,
modifier = Modifier.weight(1f)
)
Switch(
checked = biometricEnabled,
onCheckedChange = { biometricEnabled = it },
colors = SwitchDefaults.colors(
checkedThumbColor = Color.White,
checkedTrackColor = PrimaryBlue,
uncheckedThumbColor = Color.White,
uncheckedTrackColor = secondaryTextColor.copy(alpha = 0.3f)
)
.fillMaxHeight()
.fillMaxWidth(strength)
.clip(RoundedCornerShape(2.dp))
.background(strengthColor)
)
}
Text(
text = strengthLabel,
fontSize = 12.sp,
color = strengthColor
)
}
}
Spacer(modifier = Modifier.height(16.dp))
Spacer(modifier = Modifier.height(12.dp))
// Create Account Button
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(400, delayMillis = 600))
) {
Button(
onClick = {
if (!passwordsMatch) {
error = "Passwords don't match"
return@Button
}
// Confirm password field
TextField(
value = confirmPassword,
onValueChange = { confirmPassword = it; error = null },
placeholder = { Text("Confirm password", color = secondaryTextColor) },
singleLine = true,
visualTransformation = if (confirmPasswordVisible) VisualTransformation.None else PasswordVisualTransformation(),
trailingIcon = {
IconButton(onClick = { confirmPasswordVisible = !confirmPasswordVisible }) {
Icon(
imageVector = if (confirmPasswordVisible) TablerIcons.EyeOff else TablerIcons.Eye,
contentDescription = null,
tint = secondaryTextColor,
modifier = Modifier.size(20.dp)
)
}
},
isError = confirmPassword.isNotEmpty() && !passwordsMatch,
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(12.dp)),
colors = TextFieldDefaults.colors(
focusedTextColor = textColor,
unfocusedTextColor = textColor,
focusedContainerColor = fieldBackground,
unfocusedContainerColor = fieldBackground,
errorContainerColor = fieldBackground,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
errorIndicatorColor = Color.Transparent,
cursorColor = PrimaryBlue
),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done
)
)
isCreating = true
scope.launch {
try {
// Generate keys from seed phrase
val keyPair =
CryptoManager.generateKeyPairFromSeed(seedPhrase)
// Match status
if (confirmPassword.isNotEmpty()) {
Spacer(modifier = Modifier.height(6.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
val matchColor = if (passwordsMatch) Color(0xFF4CAF50) else Color(0xFFE53935)
Icon(
imageVector = if (passwordsMatch) TablerIcons.Check else TablerIcons.X,
contentDescription = null,
tint = matchColor,
modifier = Modifier.size(14.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = if (passwordsMatch) "Passwords match" else "Passwords don't match",
fontSize = 12.sp,
color = matchColor
)
}
}
// Encrypt private key and seed phrase
val encryptedPrivateKey =
CryptoManager.encryptWithPassword(
keyPair.privateKey,
password
)
val encryptedSeedPhrase =
CryptoManager.encryptWithPassword(
seedPhrase.joinToString(" "),
password
)
// Error
error?.let {
Spacer(modifier = Modifier.height(12.dp))
Text(text = it, fontSize = 13.sp, color = Color(0xFFE53935), textAlign = TextAlign.Center)
}
// Save account with truncated public key as name
val truncatedKey =
"${keyPair.publicKey.take(6)}...${keyPair.publicKey.takeLast(4)}"
val account =
EncryptedAccount(
publicKey = keyPair.publicKey,
encryptedPrivateKey = encryptedPrivateKey,
encryptedSeedPhrase = encryptedSeedPhrase,
name = truncatedKey
)
Spacer(modifier = Modifier.weight(1f))
accountManager.saveAccount(account)
Spacer(modifier = Modifier.height(16.dp))
// 🔌 Connect to server and authenticate
val privateKeyHash =
CryptoManager.generatePrivateKeyHash(
keyPair.privateKey
)
startAuthHandshakeFast(
keyPair.publicKey,
privateKeyHash
)
accountManager.setCurrentAccount(keyPair.publicKey)
// Create DecryptedAccount to pass to callback
val decryptedAccount =
DecryptedAccount(
publicKey = keyPair.publicKey,
privateKey = keyPair.privateKey,
seedPhrase = seedPhrase,
privateKeyHash = privateKeyHash,
name = truncatedKey
)
// Save biometric preference
if (biometricEnabled && biometricAvailable) {
try {
biometricPrefs.enableBiometric()
biometricPrefs.saveEncryptedPassword(
keyPair.publicKey,
CryptoManager.encryptWithPassword(password, keyPair.publicKey)
)
} catch (_: Exception) {}
}
onAccountCreated(decryptedAccount)
} catch (e: Exception) {
error = "Failed to create account: ${e.message}"
isCreating = false
}
}
},
enabled = canContinue,
modifier = Modifier.fillMaxWidth().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)
) {
if (isCreating) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = Color.White,
strokeWidth = 2.dp
// Create button
Button(
onClick = {
if (!passwordsMatch) {
error = "Passwords don't match"
return@Button
}
isCreating = true
scope.launch {
try {
val keyPair = CryptoManager.generateKeyPairFromSeed(seedPhrase)
val encryptedPrivateKey = CryptoManager.encryptWithPassword(keyPair.privateKey, password)
val encryptedSeedPhrase = CryptoManager.encryptWithPassword(seedPhrase.joinToString(" "), password)
val truncatedKey = "${keyPair.publicKey.take(6)}...${keyPair.publicKey.takeLast(4)}"
val account = EncryptedAccount(
publicKey = keyPair.publicKey,
encryptedPrivateKey = encryptedPrivateKey,
encryptedSeedPhrase = encryptedSeedPhrase,
name = truncatedKey
)
} else {
Text(
text = if (isImportMode) "Recover Account" else "Create Account",
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold
accountManager.saveAccount(account)
val privateKeyHash = CryptoManager.generatePrivateKeyHash(keyPair.privateKey)
startAuthHandshakeFast(keyPair.publicKey, privateKeyHash)
accountManager.setCurrentAccount(keyPair.publicKey)
val decryptedAccount = DecryptedAccount(
publicKey = keyPair.publicKey,
privateKey = keyPair.privateKey,
seedPhrase = seedPhrase,
privateKeyHash = privateKeyHash,
name = truncatedKey
)
onAccountCreated(decryptedAccount)
} catch (e: Exception) {
error = "Failed to create account: ${e.message}"
isCreating = false
}
}
},
enabled = canContinue,
modifier = Modifier.fillMaxWidth().height(50.dp),
colors = ButtonDefaults.buttonColors(
containerColor = PrimaryBlue,
contentColor = Color.White,
disabledContainerColor = PrimaryBlue.copy(alpha = 0.4f),
disabledContentColor = Color.White.copy(alpha = 0.5f)
),
shape = RoundedCornerShape(12.dp)
) {
if (isCreating) {
CircularProgressIndicator(
modifier = Modifier.size(22.dp),
color = Color.White,
strokeWidth = 2.dp
)
} else {
Text(
text = if (isImportMode) "Recover Account" else "Create Account",
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold
)
}
Spacer(modifier = Modifier.height(32.dp))
}
Spacer(modifier = Modifier.height(24.dp))
}
}
}

View File

@@ -217,6 +217,10 @@ fun UnlockScreen(
// Проверяем доступность биометрии
biometricAvailable = biometricManager.isBiometricAvailable()
val accountKey = targetAccount?.publicKey ?: accounts.firstOrNull()?.publicKey ?: ""
if (accountKey.isNotEmpty()) {
biometricPrefs.loadForAccount(accountKey)
}
isBiometricEnabled = biometricPrefs.isBiometricEnabled.first()
// Загружаем сохранённые пароли для всех аккаунтов
@@ -441,6 +445,8 @@ fun UnlockScreen(
isDropdownExpanded = false
password = ""
error = null
biometricPrefs.loadForAccount(account.publicKey)
isBiometricEnabled = biometricPrefs.isBiometricEnabledForAccount(account.publicKey)
}
.background(
if (isSelected) PrimaryBlue.copy(alpha = 0.1f)

View File

@@ -21,6 +21,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@@ -1132,11 +1133,10 @@ fun GroupSetupScreen(
contentColor = if (actionEnabled) Color.White else disabledActionContentColor,
shape = CircleShape,
modifier = run {
// Берём максимум из всех позиций — при переключении keyboard↔emoji
// одна уходит вниз, другая уже на месте, FAB не прыгает.
val navBarHeight = with(density) { WindowInsets.navigationBars.getBottom(density).toDp() }
val keyboardBottom = if (imeBottomDp > 0.dp) imeBottomDp + 14.dp else 0.dp
val emojiBottom = if (coordinator.isEmojiBoxVisible && coordinator.emojiHeight > 0.dp) coordinator.emojiHeight + 14.dp else 0.dp
val fabBottom = maxOf(keyboardBottom, emojiBottom, 18.dp)
val fabBottom = maxOf(keyboardBottom, emojiBottom, navBarHeight + 18.dp)
Modifier
.align(Alignment.BottomEnd)
.padding(end = 16.dp, bottom = fabBottom)

View File

@@ -25,6 +25,8 @@ import com.airbnb.lottie.compose.*
import com.airbnb.lottie.LottieComposition
import com.rosetta.messenger.network.SearchUser
import com.rosetta.messenger.R
import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.components.AvatarImage
import com.rosetta.messenger.ui.components.VerifiedBadge
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
@@ -39,6 +41,7 @@ fun SearchResultsList(
currentUserPublicKey: String,
isDarkTheme: Boolean,
preloadedComposition: LottieComposition? = null,
avatarRepository: AvatarRepository? = null,
onUserClick: (SearchUser) -> Unit,
modifier: Modifier = Modifier
) {
@@ -144,6 +147,7 @@ fun SearchResultsList(
isOwnAccount = user.publicKey == currentUserPublicKey,
isDarkTheme = isDarkTheme,
isLastItem = index == searchResults.size - 1,
avatarRepository = avatarRepository,
onClick = { onUserClick(user) }
)
}
@@ -159,18 +163,13 @@ private fun SearchResultItem(
isOwnAccount: Boolean,
isDarkTheme: Boolean,
isLastItem: Boolean,
avatarRepository: AvatarRepository? = null,
onClick: () -> Unit
) {
val textColor = if (isDarkTheme) Color.White else Color.Black
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
val dividerColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFE8E8E8)
// Получаем цвета аватара
val avatarColors = getAvatarColor(
if (isOwnAccount) "SavedMessages" else user.publicKey,
isDarkTheme
)
Column {
Row(
modifier = Modifier
@@ -179,43 +178,29 @@ private fun SearchResultItem(
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Avatar - clean and simple
Box(
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.background(if (isOwnAccount) Color(0xFF228BE6) else avatarColors.backgroundColor),
contentAlignment = Alignment.Center
) {
if (isOwnAccount) {
if (isOwnAccount) {
Box(
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.background(Color(0xFF228BE6)),
contentAlignment = Alignment.Center
) {
Icon(
painter = painterResource(R.drawable.bookmark_outlined),
contentDescription = "Saved Messages",
tint = Color.White,
modifier = Modifier.size(20.dp)
)
} else {
// Приоритет: title -> username -> publicKey
val initials = when {
user.title.isNotEmpty() &&
user.title != user.publicKey &&
!user.title.startsWith(user.publicKey.take(7)) -> {
getInitials(user.title)
}
user.username.isNotEmpty() -> {
user.username.take(2).uppercase()
}
else -> {
user.publicKey.take(2).uppercase()
}
}
Text(
text = initials,
fontSize = 15.sp,
fontWeight = FontWeight.SemiBold,
color = avatarColors.textColor
)
}
} else {
AvatarImage(
publicKey = user.publicKey,
avatarRepository = avatarRepository,
size = 48.dp,
isDarkTheme = isDarkTheme,
displayName = user.title.ifEmpty { user.username }
)
}
Spacer(modifier = Modifier.width(12.dp))

View File

@@ -720,6 +720,7 @@ private fun ChatsTabContent(
currentUserPublicKey = currentUserPublicKey,
isDarkTheme = isDarkTheme,
preloadedComposition = searchLottieComposition,
avatarRepository = avatarRepository,
onUserClick = { user ->
hideKeyboardInstantly()
if (user.publicKey != currentUserPublicKey) {

View File

@@ -309,8 +309,11 @@ fun ProfileScreen(
mutableStateOf<BiometricAvailability>(BiometricAvailability.NotAvailable("Checking..."))
}
// Biometric enabled state - read directly to always show current state
// Biometric enabled state - per account
val biometricPrefs = remember { BiometricPreferences(context) }
LaunchedEffect(accountPublicKey) {
biometricPrefs.loadForAccount(accountPublicKey)
}
val isBiometricEnabled by biometricPrefs.isBiometricEnabled.collectAsState(initial = false)
// Check biometric availability
@@ -458,6 +461,15 @@ fun ProfileScreen(
// Мёртвой зоны нет — каждый пиксель скролла двигает хедер.
// ═══════════════════════════════════════════════════════════════
val listState = rememberLazyListState()
val focusManager = androidx.compose.ui.platform.LocalFocusManager.current
// Hide keyboard when scrolling
LaunchedEffect(listState.isScrollInProgress) {
if (listState.isScrollInProgress) {
focusManager.clearFocus()
}
}
val spacerHeightDp = with(density) { maxScrollOffset.toDp() }
val collapsedHeightDp = with(density) { collapsedHeightPx.toDp() }

View File

@@ -87,7 +87,14 @@ object NavigationModeUtils {
window.isNavigationBarContrastEnforced = false
}
} else {
window.navigationBarColor = if (isDarkTheme) 0xFF1E1E1E.toInt() else 0xFFFFFFFF.toInt()
val targetColor = if (isDarkTheme) 0xFF1E1E1E.toInt() else 0xFFFFFFFF.toInt()
val currentColor = window.navigationBarColor
if (currentColor != targetColor) {
val animator = android.animation.ValueAnimator.ofArgb(currentColor, targetColor)
animator.duration = 350
animator.addUpdateListener { window.navigationBarColor = it.animatedValue as Int }
animator.start()
}
insetsController.isAppearanceLightNavigationBars = !isDarkTheme
val newFlags =
decorView.systemUiVisibility and View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION.inv()