Онбординг: отдельный экран биометрии, новый UI пароля (Telegram-style), Skip на всех шагах. Биометрия per-account. Навбар плавно анимируется при смене темы. Поиск: аватарки в результатах. Профиль: клавиатура прячется при скролле. Фокус сбрасывается при навигации.
This commit is contained in:
@@ -1037,6 +1037,12 @@ fun MainScreen(
|
|||||||
// Anti-spam: do not stack duplicate screens from rapid taps.
|
// Anti-spam: do not stack duplicate screens from rapid taps.
|
||||||
if (navStack.lastOrNull() == screen) return
|
if (navStack.lastOrNull() == screen) return
|
||||||
if (screen is Screen.Requests && navStack.any { it is Screen.Requests }) 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
|
navStack = navStack + screen
|
||||||
}
|
}
|
||||||
fun isCurrentAccountUser(user: SearchUser): Boolean {
|
fun isCurrentAccountUser(user: SearchUser): Boolean {
|
||||||
@@ -1768,7 +1774,7 @@ fun MainScreen(
|
|||||||
accountPublicKey,
|
accountPublicKey,
|
||||||
encryptedPassword
|
encryptedPassword
|
||||||
)
|
)
|
||||||
biometricPrefs.enableBiometric()
|
biometricPrefs.enableBiometric(accountPublicKey)
|
||||||
onSuccess()
|
onSuccess()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -15,20 +15,18 @@ import kotlinx.coroutines.withContext
|
|||||||
* Безопасное хранилище настроек биометрической аутентификации
|
* Безопасное хранилище настроек биометрической аутентификации
|
||||||
* Использует EncryptedSharedPreferences с MasterKey из Android Keystore
|
* Использует EncryptedSharedPreferences с MasterKey из Android Keystore
|
||||||
*
|
*
|
||||||
* Уровни защиты:
|
* Биометрия привязана к конкретному аккаунту (per-account), не глобальная.
|
||||||
* - AES256_GCM для шифрования значений
|
|
||||||
* - AES256_SIV для шифрования ключей
|
|
||||||
* - MasterKey хранится в Android Keystore (TEE/StrongBox)
|
|
||||||
*/
|
*/
|
||||||
class BiometricPreferences(private val context: Context) {
|
class BiometricPreferences(private val context: Context) {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "BiometricPreferences"
|
private const val TAG = "BiometricPreferences"
|
||||||
private const val PREFS_FILE_NAME = "rosetta_secure_biometric_prefs"
|
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_"
|
private const val ENCRYPTED_PASSWORD_PREFIX = "encrypted_password_"
|
||||||
// Shared between all BiometricPreferences instances so UI in different screens
|
// Legacy key (global) — for migration
|
||||||
// receives updates immediately (ProfileScreen <-> BiometricEnableScreen).
|
private const val KEY_BIOMETRIC_ENABLED_LEGACY = "biometric_enabled"
|
||||||
|
// Shared state for reactive UI updates
|
||||||
private val biometricEnabledState = MutableStateFlow(false)
|
private val biometricEnabledState = MutableStateFlow(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,23 +37,11 @@ class BiometricPreferences(private val context: Context) {
|
|||||||
createEncryptedPreferences()
|
createEncryptedPreferences()
|
||||||
}
|
}
|
||||||
|
|
||||||
init {
|
|
||||||
// Загружаем начальное значение
|
|
||||||
try {
|
|
||||||
_isBiometricEnabled.value = encryptedPrefs.getBoolean(KEY_BIOMETRIC_ENABLED, false)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Создает EncryptedSharedPreferences с максимальной защитой
|
|
||||||
*/
|
|
||||||
private fun createEncryptedPreferences(): SharedPreferences {
|
private fun createEncryptedPreferences(): SharedPreferences {
|
||||||
try {
|
try {
|
||||||
// Создаем MasterKey с максимальной защитой
|
|
||||||
val masterKey = MasterKey.Builder(appContext)
|
val masterKey = MasterKey.Builder(appContext)
|
||||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||||
.setUserAuthenticationRequired(false) // Биометрия проверяется отдельно в BiometricAuthManager
|
.setUserAuthenticationRequired(false)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
return EncryptedSharedPreferences.create(
|
return EncryptedSharedPreferences.create(
|
||||||
@@ -66,77 +52,93 @@ class BiometricPreferences(private val context: Context) {
|
|||||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
// Fallback на обычные SharedPreferences в случае ошибки (не должно произойти)
|
|
||||||
return appContext.getSharedPreferences(PREFS_FILE_NAME + "_fallback", Context.MODE_PRIVATE)
|
return appContext.getSharedPreferences(PREFS_FILE_NAME + "_fallback", Context.MODE_PRIVATE)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Включена ли биометрическая аутентификация
|
|
||||||
*/
|
|
||||||
val isBiometricEnabled: Flow<Boolean> = _isBiometricEnabled.asStateFlow()
|
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) {
|
suspend fun enableBiometric() = withContext(Dispatchers.IO) {
|
||||||
val success = encryptedPrefs.edit().putBoolean(KEY_BIOMETRIC_ENABLED, true).commit()
|
encryptedPrefs.edit().putBoolean(KEY_BIOMETRIC_ENABLED_LEGACY, true).commit()
|
||||||
if (!success) {
|
_isBiometricEnabled.value = true
|
||||||
Log.w(TAG, "Failed to persist biometric enabled state")
|
|
||||||
}
|
|
||||||
_isBiometricEnabled.value = encryptedPrefs.getBoolean(KEY_BIOMETRIC_ENABLED, false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
@Deprecated("Use disableBiometric(publicKey) instead")
|
||||||
* Отключить биометрическую аутентификацию
|
|
||||||
*/
|
|
||||||
suspend fun disableBiometric() = withContext(Dispatchers.IO) {
|
suspend fun disableBiometric() = withContext(Dispatchers.IO) {
|
||||||
val success = encryptedPrefs.edit().putBoolean(KEY_BIOMETRIC_ENABLED, false).commit()
|
encryptedPrefs.edit().putBoolean(KEY_BIOMETRIC_ENABLED_LEGACY, false).commit()
|
||||||
if (!success) {
|
_isBiometricEnabled.value = false
|
||||||
Log.w(TAG, "Failed to persist biometric disabled state")
|
|
||||||
}
|
|
||||||
_isBiometricEnabled.value = encryptedPrefs.getBoolean(KEY_BIOMETRIC_ENABLED, false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Сохранить зашифрованный пароль для аккаунта
|
|
||||||
* Пароль уже зашифрован BiometricAuthManager, здесь добавляется второй слой шифрования
|
|
||||||
*/
|
|
||||||
suspend fun saveEncryptedPassword(publicKey: String, encryptedPassword: String) = withContext(Dispatchers.IO) {
|
suspend fun saveEncryptedPassword(publicKey: String, encryptedPassword: String) = withContext(Dispatchers.IO) {
|
||||||
val key = "$ENCRYPTED_PASSWORD_PREFIX$publicKey"
|
val key = "$ENCRYPTED_PASSWORD_PREFIX$publicKey"
|
||||||
encryptedPrefs.edit().putString(key, encryptedPassword).apply()
|
encryptedPrefs.edit().putString(key, encryptedPassword).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Получить зашифрованный пароль для аккаунта
|
|
||||||
*/
|
|
||||||
suspend fun getEncryptedPassword(publicKey: String): String? = withContext(Dispatchers.IO) {
|
suspend fun getEncryptedPassword(publicKey: String): String? = withContext(Dispatchers.IO) {
|
||||||
val key = "$ENCRYPTED_PASSWORD_PREFIX$publicKey"
|
val key = "$ENCRYPTED_PASSWORD_PREFIX$publicKey"
|
||||||
encryptedPrefs.getString(key, null)
|
encryptedPrefs.getString(key, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Удалить зашифрованный пароль для аккаунта
|
|
||||||
*/
|
|
||||||
suspend fun removeEncryptedPassword(publicKey: String) = withContext(Dispatchers.IO) {
|
suspend fun removeEncryptedPassword(publicKey: String) = withContext(Dispatchers.IO) {
|
||||||
val key = "$ENCRYPTED_PASSWORD_PREFIX$publicKey"
|
val key = "$ENCRYPTED_PASSWORD_PREFIX$publicKey"
|
||||||
encryptedPrefs.edit().remove(key).apply()
|
encryptedPrefs.edit().remove(key).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Удалить все биометрические данные
|
|
||||||
*/
|
|
||||||
suspend fun clearAll() = withContext(Dispatchers.IO) {
|
suspend fun clearAll() = withContext(Dispatchers.IO) {
|
||||||
val success = encryptedPrefs.edit().clear().commit()
|
encryptedPrefs.edit().clear().commit()
|
||||||
if (!success) {
|
_isBiometricEnabled.value = false
|
||||||
Log.w(TAG, "Failed to clear biometric preferences")
|
|
||||||
}
|
|
||||||
_isBiometricEnabled.value = encryptedPrefs.getBoolean(KEY_BIOMETRIC_ENABLED, false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Проверить, есть ли сохраненный зашифрованный пароль для аккаунта
|
|
||||||
*/
|
|
||||||
suspend fun hasEncryptedPassword(publicKey: String): Boolean {
|
suspend fun hasEncryptedPassword(publicKey: String): Boolean {
|
||||||
return getEncryptedPassword(publicKey) != null
|
return getEncryptedPassword(publicKey) != null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ enum class AuthScreen {
|
|||||||
SEED_PHRASE,
|
SEED_PHRASE,
|
||||||
CONFIRM_SEED,
|
CONFIRM_SEED,
|
||||||
SET_PASSWORD,
|
SET_PASSWORD,
|
||||||
|
SET_BIOMETRIC,
|
||||||
SET_PROFILE,
|
SET_PROFILE,
|
||||||
IMPORT_SEED,
|
IMPORT_SEED,
|
||||||
UNLOCK
|
UNLOCK
|
||||||
@@ -87,8 +88,10 @@ fun AuthFlow(
|
|||||||
currentScreen = AuthScreen.SEED_PHRASE
|
currentScreen = AuthScreen.SEED_PHRASE
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
AuthScreen.SET_BIOMETRIC -> {
|
||||||
|
currentScreen = AuthScreen.SET_PROFILE
|
||||||
|
}
|
||||||
AuthScreen.SET_PROFILE -> {
|
AuthScreen.SET_PROFILE -> {
|
||||||
// Skip profile setup — complete auth
|
|
||||||
onAuthComplete(createdAccount)
|
onAuthComplete(createdAccount)
|
||||||
}
|
}
|
||||||
AuthScreen.IMPORT_SEED -> {
|
AuthScreen.IMPORT_SEED -> {
|
||||||
@@ -180,12 +183,20 @@ fun AuthFlow(
|
|||||||
onAuthComplete(account)
|
onAuthComplete(account)
|
||||||
} else {
|
} else {
|
||||||
createdAccount = account
|
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 -> {
|
AuthScreen.SET_PROFILE -> {
|
||||||
SetProfileScreen(
|
SetProfileScreen(
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,9 +25,6 @@ import androidx.compose.ui.text.input.VisualTransformation
|
|||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
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.crypto.CryptoManager
|
||||||
import com.rosetta.messenger.data.AccountManager
|
import com.rosetta.messenger.data.AccountManager
|
||||||
import com.rosetta.messenger.data.DecryptedAccount
|
import com.rosetta.messenger.data.DecryptedAccount
|
||||||
@@ -44,28 +41,10 @@ fun SetPasswordScreen(
|
|||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onAccountCreated: (DecryptedAccount) -> Unit
|
onAccountCreated: (DecryptedAccount) -> Unit
|
||||||
) {
|
) {
|
||||||
val themeAnimSpec =
|
val backgroundColor = if (isDarkTheme) AuthBackground else AuthBackgroundLight
|
||||||
tween<Color>(durationMillis = 500, easing = CubicBezierEasing(0.4f, 0f, 0.2f, 1f))
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
val backgroundColor by
|
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF8E8E93)
|
||||||
animateColorAsState(
|
val fieldBackground = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFF2F2F7)
|
||||||
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 context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val accountManager = remember { AccountManager(context) }
|
val accountManager = remember { AccountManager(context) }
|
||||||
@@ -77,110 +56,57 @@ fun SetPasswordScreen(
|
|||||||
var confirmPasswordVisible by remember { mutableStateOf(false) }
|
var confirmPasswordVisible by remember { mutableStateOf(false) }
|
||||||
var isCreating by remember { mutableStateOf(false) }
|
var isCreating by remember { mutableStateOf(false) }
|
||||||
var error by remember { mutableStateOf<String?>(null) }
|
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
|
val view = LocalView.current
|
||||||
if (!view.isInEditMode) {
|
if (!view.isInEditMode) {
|
||||||
SideEffect {
|
SideEffect {
|
||||||
val window = (view.context as android.app.Activity).window
|
val window = (view.context as android.app.Activity).window
|
||||||
val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view)
|
val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view)
|
||||||
// Auth screens should always keep white status bar icons.
|
|
||||||
insetsController.isAppearanceLightStatusBars = false
|
insetsController.isAppearanceLightStatusBars = false
|
||||||
window.statusBarColor = android.graphics.Color.TRANSPARENT
|
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 passwordsMatch = password == confirmPassword && password.isNotEmpty()
|
||||||
val isPasswordWeak = password.isNotEmpty() && password.length < 6
|
val isPasswordWeak = password.isNotEmpty() && password.length < 6
|
||||||
val canContinue = passwordsMatch && !isCreating
|
val canContinue = passwordsMatch && !isCreating
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize().background(backgroundColor).navigationBarsPadding()) {
|
Scaffold(
|
||||||
Column(modifier = Modifier.fillMaxSize().statusBarsPadding()) {
|
containerColor = backgroundColor,
|
||||||
// Top Bar
|
topBar = {
|
||||||
Row(
|
TopAppBar(
|
||||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 8.dp),
|
title = {},
|
||||||
verticalAlignment = Alignment.CenterVertically
|
navigationIcon = {
|
||||||
) {
|
|
||||||
IconButton(onClick = onBack, enabled = !isCreating) {
|
IconButton(onClick = onBack, enabled = !isCreating) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = TablerIcons.ChevronLeft,
|
imageVector = TablerIcons.ChevronLeft,
|
||||||
contentDescription = "Back",
|
contentDescription = "Back",
|
||||||
tint = textColor.copy(alpha = 0.6f)
|
tint = textColor
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
},
|
||||||
Text(
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
text = "Set Password",
|
containerColor = Color.Transparent
|
||||||
fontSize = 18.sp,
|
)
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
color = textColor
|
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
|
||||||
Spacer(modifier = Modifier.width(48.dp))
|
|
||||||
}
|
}
|
||||||
|
) { paddingValues ->
|
||||||
Column(
|
Column(
|
||||||
modifier =
|
modifier = Modifier
|
||||||
Modifier.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
.padding(paddingValues)
|
||||||
.imePadding()
|
.imePadding()
|
||||||
.padding(horizontal = 24.dp)
|
.verticalScroll(rememberScrollState())
|
||||||
.verticalScroll(rememberScrollState()),
|
.padding(horizontal = 24.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
Spacer(modifier = Modifier.height(if (isKeyboardVisible) 8.dp else 16.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
// Lock Icon - smaller when keyboard is visible
|
// Lock icon
|
||||||
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)
|
|
||||||
)
|
|
||||||
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = visible,
|
|
||||||
enter =
|
|
||||||
fadeIn(tween(250)) +
|
|
||||||
scaleIn(
|
|
||||||
initialScale = 0.5f,
|
|
||||||
animationSpec =
|
|
||||||
tween(250, easing = FastOutSlowInEasing)
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier = Modifier
|
||||||
Modifier.size(iconSize)
|
.size(56.dp)
|
||||||
.clip(
|
.clip(RoundedCornerShape(16.dp))
|
||||||
RoundedCornerShape(
|
|
||||||
if (isKeyboardVisible) 12.dp else 20.dp
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.background(PrimaryBlue.copy(alpha = 0.1f)),
|
.background(PrimaryBlue.copy(alpha = 0.1f)),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
@@ -188,419 +114,209 @@ fun SetPasswordScreen(
|
|||||||
TablerIcons.Lock,
|
TablerIcons.Lock,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = PrimaryBlue,
|
tint = PrimaryBlue,
|
||||||
modifier = Modifier.size(iconInnerSize)
|
modifier = Modifier.size(28.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(if (isKeyboardVisible) 12.dp else 24.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = visible,
|
|
||||||
enter = fadeIn(tween(400, delayMillis = 100))
|
|
||||||
) {
|
|
||||||
Text(
|
Text(
|
||||||
text = if (isImportMode) "Recover Account" else "Protect Your Account",
|
text = if (isImportMode) "Recover Account" else "Protect Your Account",
|
||||||
fontSize = if (isKeyboardVisible) 20.sp else 24.sp,
|
fontSize = 20.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = textColor
|
color = textColor
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(if (isKeyboardVisible) 6.dp else 8.dp))
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = visible,
|
|
||||||
enter = fadeIn(tween(500, delayMillis = 200))
|
|
||||||
) {
|
|
||||||
Text(
|
Text(
|
||||||
text = if (isImportMode)
|
text = "This password encrypts your keys locally.\nYou'll need it to unlock Rosetta.",
|
||||||
"Set a password to protect your recovered account.\nYou'll need it to unlock Rosetta."
|
fontSize = 14.sp,
|
||||||
else
|
|
||||||
"This password encrypts your keys locally.\nYou'll need it to unlock Rosetta.",
|
|
||||||
fontSize = if (isKeyboardVisible) 12.sp else 14.sp,
|
|
||||||
color = secondaryTextColor,
|
color = secondaryTextColor,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
lineHeight = if (isKeyboardVisible) 16.sp else 20.sp
|
lineHeight = 20.sp
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(if (isKeyboardVisible) 16.dp else 32.dp))
|
Spacer(modifier = Modifier.height(28.dp))
|
||||||
|
|
||||||
// Password Field
|
// Password field — clean Telegram style
|
||||||
AnimatedVisibility(
|
TextField(
|
||||||
visible = visible,
|
|
||||||
enter = fadeIn(tween(400, delayMillis = 300))
|
|
||||||
) {
|
|
||||||
OutlinedTextField(
|
|
||||||
value = password,
|
value = password,
|
||||||
onValueChange = {
|
onValueChange = { password = it; error = null },
|
||||||
password = it
|
placeholder = { Text("Password", color = secondaryTextColor) },
|
||||||
error = null
|
|
||||||
},
|
|
||||||
label = { Text("Password") },
|
|
||||||
placeholder = { Text("Enter password") },
|
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
visualTransformation =
|
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
|
||||||
if (passwordVisible) VisualTransformation.None
|
|
||||||
else PasswordVisualTransformation(),
|
|
||||||
trailingIcon = {
|
trailingIcon = {
|
||||||
IconButton(onClick = { passwordVisible = !passwordVisible }) {
|
IconButton(onClick = { passwordVisible = !passwordVisible }) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector =
|
imageVector = if (passwordVisible) TablerIcons.EyeOff else TablerIcons.Eye,
|
||||||
if (passwordVisible) TablerIcons.EyeOff
|
contentDescription = null,
|
||||||
else TablerIcons.Eye,
|
tint = secondaryTextColor,
|
||||||
contentDescription =
|
modifier = Modifier.size(20.dp)
|
||||||
if (passwordVisible) "Hide" else "Show"
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
colors =
|
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(12.dp)),
|
||||||
OutlinedTextFieldDefaults.colors(
|
colors = TextFieldDefaults.colors(
|
||||||
focusedBorderColor = PrimaryBlue,
|
|
||||||
unfocusedBorderColor =
|
|
||||||
if (isDarkTheme) Color(0xFF4A4A4A)
|
|
||||||
else Color(0xFFD0D0D0),
|
|
||||||
focusedLabelColor = PrimaryBlue,
|
|
||||||
cursorColor = PrimaryBlue,
|
|
||||||
focusedTextColor = textColor,
|
focusedTextColor = textColor,
|
||||||
unfocusedTextColor = textColor
|
unfocusedTextColor = textColor,
|
||||||
|
focusedContainerColor = fieldBackground,
|
||||||
|
unfocusedContainerColor = fieldBackground,
|
||||||
|
focusedIndicatorColor = Color.Transparent,
|
||||||
|
unfocusedIndicatorColor = Color.Transparent,
|
||||||
|
cursorColor = PrimaryBlue
|
||||||
),
|
),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
keyboardOptions = KeyboardOptions(
|
||||||
shape = RoundedCornerShape(12.dp),
|
|
||||||
keyboardOptions =
|
|
||||||
KeyboardOptions(
|
|
||||||
keyboardType = KeyboardType.Password,
|
keyboardType = KeyboardType.Password,
|
||||||
imeAction = ImeAction.Next
|
imeAction = ImeAction.Next
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
// Password strength indicator
|
// Strength bar
|
||||||
if (password.isNotEmpty()) {
|
if (password.isNotEmpty()) {
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
AnimatedVisibility(
|
val strength = when {
|
||||||
visible = visible,
|
password.length < 6 -> 0.25f
|
||||||
enter = fadeIn(tween(400, delayMillis = 350))
|
password.length < 10 -> 0.6f
|
||||||
) {
|
else -> 1f
|
||||||
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 =
|
val strengthColor = when {
|
||||||
when {
|
|
||||||
password.length < 6 -> Color(0xFFE53935)
|
password.length < 6 -> Color(0xFFE53935)
|
||||||
password.length < 10 -> Color(0xFFFFA726)
|
password.length < 10 -> Color(0xFFFFA726)
|
||||||
else -> Color(0xFF4CAF50)
|
else -> Color(0xFF4CAF50)
|
||||||
}
|
}
|
||||||
Icon(
|
val strengthLabel = when {
|
||||||
painter = TelegramIcons.Secret,
|
password.length < 6 -> "Weak"
|
||||||
contentDescription = null,
|
password.length < 10 -> "Medium"
|
||||||
tint = strengthColor,
|
else -> "Strong"
|
||||||
modifier = Modifier.size(16.dp)
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.height(3.dp)
|
||||||
|
.clip(RoundedCornerShape(2.dp))
|
||||||
|
.background(fieldBackground)
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxHeight()
|
||||||
|
.fillMaxWidth(strength)
|
||||||
|
.clip(RoundedCornerShape(2.dp))
|
||||||
|
.background(strengthColor)
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
}
|
||||||
Text(
|
Text(
|
||||||
text = "Password strength: $strength",
|
text = strengthLabel,
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
color = strengthColor
|
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))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
// Confirm Password Field
|
// Confirm password field
|
||||||
AnimatedVisibility(
|
TextField(
|
||||||
visible = visible,
|
|
||||||
enter = fadeIn(tween(400, delayMillis = 400))
|
|
||||||
) {
|
|
||||||
OutlinedTextField(
|
|
||||||
value = confirmPassword,
|
value = confirmPassword,
|
||||||
onValueChange = {
|
onValueChange = { confirmPassword = it; error = null },
|
||||||
confirmPassword = it
|
placeholder = { Text("Confirm password", color = secondaryTextColor) },
|
||||||
error = null
|
|
||||||
},
|
|
||||||
label = { Text("Confirm Password") },
|
|
||||||
placeholder = { Text("Re-enter password") },
|
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
visualTransformation =
|
visualTransformation = if (confirmPasswordVisible) VisualTransformation.None else PasswordVisualTransformation(),
|
||||||
if (confirmPasswordVisible) VisualTransformation.None
|
|
||||||
else PasswordVisualTransformation(),
|
|
||||||
trailingIcon = {
|
trailingIcon = {
|
||||||
IconButton(
|
IconButton(onClick = { confirmPasswordVisible = !confirmPasswordVisible }) {
|
||||||
onClick = {
|
|
||||||
confirmPasswordVisible = !confirmPasswordVisible
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Icon(
|
Icon(
|
||||||
imageVector =
|
imageVector = if (confirmPasswordVisible) TablerIcons.EyeOff else TablerIcons.Eye,
|
||||||
if (confirmPasswordVisible)
|
contentDescription = null,
|
||||||
TablerIcons.EyeOff
|
tint = secondaryTextColor,
|
||||||
else TablerIcons.Eye,
|
modifier = Modifier.size(20.dp)
|
||||||
contentDescription =
|
|
||||||
if (confirmPasswordVisible) "Hide" else "Show"
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
isError = confirmPassword.isNotEmpty() && !passwordsMatch,
|
isError = confirmPassword.isNotEmpty() && !passwordsMatch,
|
||||||
colors =
|
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(12.dp)),
|
||||||
OutlinedTextFieldDefaults.colors(
|
colors = TextFieldDefaults.colors(
|
||||||
focusedBorderColor = PrimaryBlue,
|
|
||||||
unfocusedBorderColor =
|
|
||||||
if (isDarkTheme) Color(0xFF4A4A4A)
|
|
||||||
else Color(0xFFD0D0D0),
|
|
||||||
focusedLabelColor = PrimaryBlue,
|
|
||||||
cursorColor = PrimaryBlue,
|
|
||||||
focusedTextColor = textColor,
|
focusedTextColor = textColor,
|
||||||
unfocusedTextColor = textColor
|
unfocusedTextColor = textColor,
|
||||||
|
focusedContainerColor = fieldBackground,
|
||||||
|
unfocusedContainerColor = fieldBackground,
|
||||||
|
errorContainerColor = fieldBackground,
|
||||||
|
focusedIndicatorColor = Color.Transparent,
|
||||||
|
unfocusedIndicatorColor = Color.Transparent,
|
||||||
|
errorIndicatorColor = Color.Transparent,
|
||||||
|
cursorColor = PrimaryBlue
|
||||||
),
|
),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
keyboardOptions = KeyboardOptions(
|
||||||
shape = RoundedCornerShape(12.dp),
|
|
||||||
keyboardOptions =
|
|
||||||
KeyboardOptions(
|
|
||||||
keyboardType = KeyboardType.Password,
|
keyboardType = KeyboardType.Password,
|
||||||
imeAction = ImeAction.Done
|
imeAction = ImeAction.Done
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
// Match indicator
|
// Match status
|
||||||
if (confirmPassword.isNotEmpty()) {
|
if (confirmPassword.isNotEmpty()) {
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
AnimatedVisibility(
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
visible = visible,
|
val matchColor = if (passwordsMatch) Color(0xFF4CAF50) else Color(0xFFE53935)
|
||||||
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(
|
Icon(
|
||||||
imageVector = matchIcon,
|
imageVector = if (passwordsMatch) TablerIcons.Check else TablerIcons.X,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = matchColor,
|
tint = matchColor,
|
||||||
modifier = Modifier.size(16.dp)
|
modifier = Modifier.size(14.dp)
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
Text(text = matchText, fontSize = 12.sp, color = matchColor)
|
Text(
|
||||||
}
|
text = if (passwordsMatch) "Passwords match" else "Passwords don't match",
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = matchColor
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Error message
|
// Error
|
||||||
error?.let { errorMsg ->
|
error?.let {
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
AnimatedVisibility(
|
Text(text = it, fontSize = 13.sp, color = Color(0xFFE53935), textAlign = TextAlign.Center)
|
||||||
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))
|
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(
|
|
||||||
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)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
// Create Account Button
|
// Create button
|
||||||
AnimatedVisibility(
|
|
||||||
visible = visible,
|
|
||||||
enter = fadeIn(tween(400, delayMillis = 600))
|
|
||||||
) {
|
|
||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
if (!passwordsMatch) {
|
if (!passwordsMatch) {
|
||||||
error = "Passwords don't match"
|
error = "Passwords don't match"
|
||||||
return@Button
|
return@Button
|
||||||
}
|
}
|
||||||
|
|
||||||
isCreating = true
|
isCreating = true
|
||||||
scope.launch {
|
scope.launch {
|
||||||
try {
|
try {
|
||||||
// Generate keys from seed phrase
|
val keyPair = CryptoManager.generateKeyPairFromSeed(seedPhrase)
|
||||||
val keyPair =
|
val encryptedPrivateKey = CryptoManager.encryptWithPassword(keyPair.privateKey, password)
|
||||||
CryptoManager.generateKeyPairFromSeed(seedPhrase)
|
val encryptedSeedPhrase = CryptoManager.encryptWithPassword(seedPhrase.joinToString(" "), password)
|
||||||
|
val truncatedKey = "${keyPair.publicKey.take(6)}...${keyPair.publicKey.takeLast(4)}"
|
||||||
// Encrypt private key and seed phrase
|
val account = EncryptedAccount(
|
||||||
val encryptedPrivateKey =
|
|
||||||
CryptoManager.encryptWithPassword(
|
|
||||||
keyPair.privateKey,
|
|
||||||
password
|
|
||||||
)
|
|
||||||
val encryptedSeedPhrase =
|
|
||||||
CryptoManager.encryptWithPassword(
|
|
||||||
seedPhrase.joinToString(" "),
|
|
||||||
password
|
|
||||||
)
|
|
||||||
|
|
||||||
// Save account with truncated public key as name
|
|
||||||
val truncatedKey =
|
|
||||||
"${keyPair.publicKey.take(6)}...${keyPair.publicKey.takeLast(4)}"
|
|
||||||
val account =
|
|
||||||
EncryptedAccount(
|
|
||||||
publicKey = keyPair.publicKey,
|
publicKey = keyPair.publicKey,
|
||||||
encryptedPrivateKey = encryptedPrivateKey,
|
encryptedPrivateKey = encryptedPrivateKey,
|
||||||
encryptedSeedPhrase = encryptedSeedPhrase,
|
encryptedSeedPhrase = encryptedSeedPhrase,
|
||||||
name = truncatedKey
|
name = truncatedKey
|
||||||
)
|
)
|
||||||
|
|
||||||
accountManager.saveAccount(account)
|
accountManager.saveAccount(account)
|
||||||
|
val privateKeyHash = CryptoManager.generatePrivateKeyHash(keyPair.privateKey)
|
||||||
// 🔌 Connect to server and authenticate
|
startAuthHandshakeFast(keyPair.publicKey, privateKeyHash)
|
||||||
val privateKeyHash =
|
|
||||||
CryptoManager.generatePrivateKeyHash(
|
|
||||||
keyPair.privateKey
|
|
||||||
)
|
|
||||||
|
|
||||||
startAuthHandshakeFast(
|
|
||||||
keyPair.publicKey,
|
|
||||||
privateKeyHash
|
|
||||||
)
|
|
||||||
|
|
||||||
accountManager.setCurrentAccount(keyPair.publicKey)
|
accountManager.setCurrentAccount(keyPair.publicKey)
|
||||||
|
val decryptedAccount = DecryptedAccount(
|
||||||
// Create DecryptedAccount to pass to callback
|
|
||||||
val decryptedAccount =
|
|
||||||
DecryptedAccount(
|
|
||||||
publicKey = keyPair.publicKey,
|
publicKey = keyPair.publicKey,
|
||||||
privateKey = keyPair.privateKey,
|
privateKey = keyPair.privateKey,
|
||||||
seedPhrase = seedPhrase,
|
seedPhrase = seedPhrase,
|
||||||
privateKeyHash = privateKeyHash,
|
privateKeyHash = privateKeyHash,
|
||||||
name = truncatedKey
|
name = truncatedKey
|
||||||
)
|
)
|
||||||
|
|
||||||
// Save biometric preference
|
|
||||||
if (biometricEnabled && biometricAvailable) {
|
|
||||||
try {
|
|
||||||
biometricPrefs.enableBiometric()
|
|
||||||
biometricPrefs.saveEncryptedPassword(
|
|
||||||
keyPair.publicKey,
|
|
||||||
CryptoManager.encryptWithPassword(password, keyPair.publicKey)
|
|
||||||
)
|
|
||||||
} catch (_: Exception) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
onAccountCreated(decryptedAccount)
|
onAccountCreated(decryptedAccount)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
error = "Failed to create account: ${e.message}"
|
error = "Failed to create account: ${e.message}"
|
||||||
@@ -609,19 +325,18 @@ fun SetPasswordScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
enabled = canContinue,
|
enabled = canContinue,
|
||||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
modifier = Modifier.fillMaxWidth().height(50.dp),
|
||||||
colors =
|
colors = ButtonDefaults.buttonColors(
|
||||||
ButtonDefaults.buttonColors(
|
|
||||||
containerColor = PrimaryBlue,
|
containerColor = PrimaryBlue,
|
||||||
contentColor = Color.White,
|
contentColor = Color.White,
|
||||||
disabledContainerColor = PrimaryBlue.copy(alpha = 0.5f),
|
disabledContainerColor = PrimaryBlue.copy(alpha = 0.4f),
|
||||||
disabledContentColor = Color.White.copy(alpha = 0.5f)
|
disabledContentColor = Color.White.copy(alpha = 0.5f)
|
||||||
),
|
),
|
||||||
shape = RoundedCornerShape(12.dp)
|
shape = RoundedCornerShape(12.dp)
|
||||||
) {
|
) {
|
||||||
if (isCreating) {
|
if (isCreating) {
|
||||||
CircularProgressIndicator(
|
CircularProgressIndicator(
|
||||||
modifier = Modifier.size(24.dp),
|
modifier = Modifier.size(22.dp),
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
strokeWidth = 2.dp
|
strokeWidth = 2.dp
|
||||||
)
|
)
|
||||||
@@ -633,9 +348,8 @@ fun SetPasswordScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(32.dp))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -217,6 +217,10 @@ fun UnlockScreen(
|
|||||||
|
|
||||||
// Проверяем доступность биометрии
|
// Проверяем доступность биометрии
|
||||||
biometricAvailable = biometricManager.isBiometricAvailable()
|
biometricAvailable = biometricManager.isBiometricAvailable()
|
||||||
|
val accountKey = targetAccount?.publicKey ?: accounts.firstOrNull()?.publicKey ?: ""
|
||||||
|
if (accountKey.isNotEmpty()) {
|
||||||
|
biometricPrefs.loadForAccount(accountKey)
|
||||||
|
}
|
||||||
isBiometricEnabled = biometricPrefs.isBiometricEnabled.first()
|
isBiometricEnabled = biometricPrefs.isBiometricEnabled.first()
|
||||||
|
|
||||||
// Загружаем сохранённые пароли для всех аккаунтов
|
// Загружаем сохранённые пароли для всех аккаунтов
|
||||||
@@ -441,6 +445,8 @@ fun UnlockScreen(
|
|||||||
isDropdownExpanded = false
|
isDropdownExpanded = false
|
||||||
password = ""
|
password = ""
|
||||||
error = null
|
error = null
|
||||||
|
biometricPrefs.loadForAccount(account.publicKey)
|
||||||
|
isBiometricEnabled = biometricPrefs.isBiometricEnabledForAccount(account.publicKey)
|
||||||
}
|
}
|
||||||
.background(
|
.background(
|
||||||
if (isSelected) PrimaryBlue.copy(alpha = 0.1f)
|
if (isSelected) PrimaryBlue.copy(alpha = 0.1f)
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.ime
|
import androidx.compose.foundation.layout.ime
|
||||||
|
import androidx.compose.foundation.layout.navigationBars
|
||||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
@@ -1132,11 +1133,10 @@ fun GroupSetupScreen(
|
|||||||
contentColor = if (actionEnabled) Color.White else disabledActionContentColor,
|
contentColor = if (actionEnabled) Color.White else disabledActionContentColor,
|
||||||
shape = CircleShape,
|
shape = CircleShape,
|
||||||
modifier = run {
|
modifier = run {
|
||||||
// Берём максимум из всех позиций — при переключении keyboard↔emoji
|
val navBarHeight = with(density) { WindowInsets.navigationBars.getBottom(density).toDp() }
|
||||||
// одна уходит вниз, другая уже на месте, FAB не прыгает.
|
|
||||||
val keyboardBottom = if (imeBottomDp > 0.dp) imeBottomDp + 14.dp else 0.dp
|
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 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
|
Modifier
|
||||||
.align(Alignment.BottomEnd)
|
.align(Alignment.BottomEnd)
|
||||||
.padding(end = 16.dp, bottom = fabBottom)
|
.padding(end = 16.dp, bottom = fabBottom)
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ import com.airbnb.lottie.compose.*
|
|||||||
import com.airbnb.lottie.LottieComposition
|
import com.airbnb.lottie.LottieComposition
|
||||||
import com.rosetta.messenger.network.SearchUser
|
import com.rosetta.messenger.network.SearchUser
|
||||||
import com.rosetta.messenger.R
|
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.components.VerifiedBadge
|
||||||
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
||||||
|
|
||||||
@@ -39,6 +41,7 @@ fun SearchResultsList(
|
|||||||
currentUserPublicKey: String,
|
currentUserPublicKey: String,
|
||||||
isDarkTheme: Boolean,
|
isDarkTheme: Boolean,
|
||||||
preloadedComposition: LottieComposition? = null,
|
preloadedComposition: LottieComposition? = null,
|
||||||
|
avatarRepository: AvatarRepository? = null,
|
||||||
onUserClick: (SearchUser) -> Unit,
|
onUserClick: (SearchUser) -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
@@ -144,6 +147,7 @@ fun SearchResultsList(
|
|||||||
isOwnAccount = user.publicKey == currentUserPublicKey,
|
isOwnAccount = user.publicKey == currentUserPublicKey,
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
isLastItem = index == searchResults.size - 1,
|
isLastItem = index == searchResults.size - 1,
|
||||||
|
avatarRepository = avatarRepository,
|
||||||
onClick = { onUserClick(user) }
|
onClick = { onUserClick(user) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -159,18 +163,13 @@ private fun SearchResultItem(
|
|||||||
isOwnAccount: Boolean,
|
isOwnAccount: Boolean,
|
||||||
isDarkTheme: Boolean,
|
isDarkTheme: Boolean,
|
||||||
isLastItem: Boolean,
|
isLastItem: Boolean,
|
||||||
|
avatarRepository: AvatarRepository? = null,
|
||||||
onClick: () -> Unit
|
onClick: () -> Unit
|
||||||
) {
|
) {
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
|
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
|
||||||
val dividerColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFE8E8E8)
|
val dividerColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFE8E8E8)
|
||||||
|
|
||||||
// Получаем цвета аватара
|
|
||||||
val avatarColors = getAvatarColor(
|
|
||||||
if (isOwnAccount) "SavedMessages" else user.publicKey,
|
|
||||||
isDarkTheme
|
|
||||||
)
|
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -179,44 +178,30 @@ private fun SearchResultItem(
|
|||||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
// Avatar - clean and simple
|
if (isOwnAccount) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(48.dp)
|
.size(48.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(if (isOwnAccount) Color(0xFF228BE6) else avatarColors.backgroundColor),
|
.background(Color(0xFF228BE6)),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
if (isOwnAccount) {
|
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(R.drawable.bookmark_outlined),
|
painter = painterResource(R.drawable.bookmark_outlined),
|
||||||
contentDescription = "Saved Messages",
|
contentDescription = "Saved Messages",
|
||||||
tint = Color.White,
|
tint = Color.White,
|
||||||
modifier = Modifier.size(20.dp)
|
modifier = Modifier.size(20.dp)
|
||||||
)
|
)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Приоритет: title -> username -> publicKey
|
AvatarImage(
|
||||||
val initials = when {
|
publicKey = user.publicKey,
|
||||||
user.title.isNotEmpty() &&
|
avatarRepository = avatarRepository,
|
||||||
user.title != user.publicKey &&
|
size = 48.dp,
|
||||||
!user.title.startsWith(user.publicKey.take(7)) -> {
|
isDarkTheme = isDarkTheme,
|
||||||
getInitials(user.title)
|
displayName = user.title.ifEmpty { user.username }
|
||||||
}
|
|
||||||
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
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.width(12.dp))
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
|
||||||
|
|||||||
@@ -720,6 +720,7 @@ private fun ChatsTabContent(
|
|||||||
currentUserPublicKey = currentUserPublicKey,
|
currentUserPublicKey = currentUserPublicKey,
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
preloadedComposition = searchLottieComposition,
|
preloadedComposition = searchLottieComposition,
|
||||||
|
avatarRepository = avatarRepository,
|
||||||
onUserClick = { user ->
|
onUserClick = { user ->
|
||||||
hideKeyboardInstantly()
|
hideKeyboardInstantly()
|
||||||
if (user.publicKey != currentUserPublicKey) {
|
if (user.publicKey != currentUserPublicKey) {
|
||||||
|
|||||||
@@ -309,8 +309,11 @@ fun ProfileScreen(
|
|||||||
mutableStateOf<BiometricAvailability>(BiometricAvailability.NotAvailable("Checking..."))
|
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) }
|
val biometricPrefs = remember { BiometricPreferences(context) }
|
||||||
|
LaunchedEffect(accountPublicKey) {
|
||||||
|
biometricPrefs.loadForAccount(accountPublicKey)
|
||||||
|
}
|
||||||
val isBiometricEnabled by biometricPrefs.isBiometricEnabled.collectAsState(initial = false)
|
val isBiometricEnabled by biometricPrefs.isBiometricEnabled.collectAsState(initial = false)
|
||||||
|
|
||||||
// Check biometric availability
|
// Check biometric availability
|
||||||
@@ -458,6 +461,15 @@ fun ProfileScreen(
|
|||||||
// Мёртвой зоны нет — каждый пиксель скролла двигает хедер.
|
// Мёртвой зоны нет — каждый пиксель скролла двигает хедер.
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
val listState = rememberLazyListState()
|
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 spacerHeightDp = with(density) { maxScrollOffset.toDp() }
|
||||||
val collapsedHeightDp = with(density) { collapsedHeightPx.toDp() }
|
val collapsedHeightDp = with(density) { collapsedHeightPx.toDp() }
|
||||||
|
|
||||||
|
|||||||
@@ -87,7 +87,14 @@ object NavigationModeUtils {
|
|||||||
window.isNavigationBarContrastEnforced = false
|
window.isNavigationBarContrastEnforced = false
|
||||||
}
|
}
|
||||||
} else {
|
} 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
|
insetsController.isAppearanceLightNavigationBars = !isDarkTheme
|
||||||
val newFlags =
|
val newFlags =
|
||||||
decorView.systemUiVisibility and View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION.inv()
|
decorView.systemUiVisibility and View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION.inv()
|
||||||
|
|||||||
Reference in New Issue
Block a user