diff --git a/app/src/main/java/com/rosetta/messenger/MainActivity.kt b/app/src/main/java/com/rosetta/messenger/MainActivity.kt index c956b73..940f08f 100644 --- a/app/src/main/java/com/rosetta/messenger/MainActivity.kt +++ b/app/src/main/java/com/rosetta/messenger/MainActivity.kt @@ -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() } }, diff --git a/app/src/main/java/com/rosetta/messenger/biometric/BiometricPreferences.kt b/app/src/main/java/com/rosetta/messenger/biometric/BiometricPreferences.kt index 95ff1f5..110890d 100644 --- a/app/src/main/java/com/rosetta/messenger/biometric/BiometricPreferences.kt +++ b/app/src/main/java/com/rosetta/messenger/biometric/BiometricPreferences.kt @@ -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 = _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 } diff --git a/app/src/main/java/com/rosetta/messenger/ui/auth/AuthFlow.kt b/app/src/main/java/com/rosetta/messenger/ui/auth/AuthFlow.kt index da24ef8..cd84f62 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/auth/AuthFlow.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/auth/AuthFlow.kt @@ -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( diff --git a/app/src/main/java/com/rosetta/messenger/ui/auth/SetBiometricScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/auth/SetBiometricScreen.kt new file mode 100644 index 0000000..8b76eae --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/ui/auth/SetBiometricScreen.kt @@ -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)) + } + } +} diff --git a/app/src/main/java/com/rosetta/messenger/ui/auth/SetPasswordScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/auth/SetPasswordScreen.kt index a5efbea..0cec146 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/auth/SetPasswordScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/auth/SetPasswordScreen.kt @@ -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(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(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)) } } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/auth/UnlockScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/auth/UnlockScreen.kt index fb5ef8d..f9b4c87 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/auth/UnlockScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/auth/UnlockScreen.kt @@ -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) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/GroupSetupScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/GroupSetupScreen.kt index 161819d..ba6541f 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/GroupSetupScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/GroupSetupScreen.kt @@ -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) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/SearchResultsList.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/SearchResultsList.kt index 9a938b1..d4589b5 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/SearchResultsList.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/SearchResultsList.kt @@ -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)) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt index cce6809..5f1ae95 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt @@ -720,6 +720,7 @@ private fun ChatsTabContent( currentUserPublicKey = currentUserPublicKey, isDarkTheme = isDarkTheme, preloadedComposition = searchLottieComposition, + avatarRepository = avatarRepository, onUserClick = { user -> hideKeyboardInstantly() if (user.publicKey != currentUserPublicKey) { diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt index 88de147..788a310 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt @@ -309,8 +309,11 @@ fun ProfileScreen( mutableStateOf(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() } diff --git a/app/src/main/java/com/rosetta/messenger/ui/utils/NavigationModeUtils.kt b/app/src/main/java/com/rosetta/messenger/ui/utils/NavigationModeUtils.kt index 4e65535..0f05cab 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/utils/NavigationModeUtils.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/utils/NavigationModeUtils.kt @@ -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()