fix: add detailed logging for unlock process to improve debugging and performance tracking
This commit is contained in:
@@ -98,6 +98,9 @@ dependencies {
|
|||||||
implementation("io.coil-kt:coil-compose:2.5.0")
|
implementation("io.coil-kt:coil-compose:2.5.0")
|
||||||
implementation("io.coil-kt:coil-gif:2.5.0") // For animated WebP/GIF support
|
implementation("io.coil-kt:coil-gif:2.5.0") // For animated WebP/GIF support
|
||||||
|
|
||||||
|
// Jsoup for HTML parsing (Link Preview OG tags)
|
||||||
|
implementation("org.jsoup:jsoup:1.17.2")
|
||||||
|
|
||||||
// uCrop for image cropping
|
// uCrop for image cropping
|
||||||
implementation("com.github.yalantis:ucrop:2.2.8")
|
implementation("com.github.yalantis:ucrop:2.2.8")
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import com.rosetta.messenger.ui.chats.SearchScreen
|
|||||||
import com.rosetta.messenger.ui.components.OptimizedEmojiCache
|
import com.rosetta.messenger.ui.components.OptimizedEmojiCache
|
||||||
import com.rosetta.messenger.ui.onboarding.OnboardingScreen
|
import com.rosetta.messenger.ui.onboarding.OnboardingScreen
|
||||||
import com.rosetta.messenger.ui.settings.BackupScreen
|
import com.rosetta.messenger.ui.settings.BackupScreen
|
||||||
|
import com.rosetta.messenger.ui.settings.BiometricEnableScreen
|
||||||
import com.rosetta.messenger.ui.settings.OtherProfileScreen
|
import com.rosetta.messenger.ui.settings.OtherProfileScreen
|
||||||
import com.rosetta.messenger.ui.settings.ProfileScreen
|
import com.rosetta.messenger.ui.settings.ProfileScreen
|
||||||
import com.rosetta.messenger.ui.settings.SafetyScreen
|
import com.rosetta.messenger.ui.settings.SafetyScreen
|
||||||
@@ -527,6 +528,7 @@ fun MainScreen(
|
|||||||
var showBackupScreen by remember { mutableStateOf(false) }
|
var showBackupScreen by remember { mutableStateOf(false) }
|
||||||
var showLogsScreen by remember { mutableStateOf(false) }
|
var showLogsScreen by remember { mutableStateOf(false) }
|
||||||
var showCrashLogsScreen by remember { mutableStateOf(false) }
|
var showCrashLogsScreen by remember { mutableStateOf(false) }
|
||||||
|
var showBiometricScreen by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
// ProfileViewModel для логов
|
// ProfileViewModel для логов
|
||||||
val profileViewModel: com.rosetta.messenger.ui.settings.ProfileViewModel = androidx.lifecycle.viewmodel.compose.viewModel()
|
val profileViewModel: com.rosetta.messenger.ui.settings.ProfileViewModel = androidx.lifecycle.viewmodel.compose.viewModel()
|
||||||
@@ -555,7 +557,8 @@ fun MainScreen(
|
|||||||
androidx.compose.animation.AnimatedVisibility(
|
androidx.compose.animation.AnimatedVisibility(
|
||||||
visible = !showBackupScreen && !showSafetyScreen && !showThemeScreen &&
|
visible = !showBackupScreen && !showSafetyScreen && !showThemeScreen &&
|
||||||
!showUpdatesScreen && selectedUser == null && !showSearchScreen &&
|
!showUpdatesScreen && selectedUser == null && !showSearchScreen &&
|
||||||
!showProfileScreen && !showOtherProfileScreen && !showLogsScreen && !showCrashLogsScreen,
|
!showProfileScreen && !showOtherProfileScreen && !showLogsScreen &&
|
||||||
|
!showCrashLogsScreen && !showBiometricScreen,
|
||||||
enter = fadeIn(animationSpec = tween(300)),
|
enter = fadeIn(animationSpec = tween(300)),
|
||||||
exit = fadeOut(animationSpec = tween(200))
|
exit = fadeOut(animationSpec = tween(200))
|
||||||
) {
|
) {
|
||||||
@@ -799,6 +802,10 @@ fun MainScreen(
|
|||||||
showProfileScreen = false
|
showProfileScreen = false
|
||||||
showCrashLogsScreen = true
|
showCrashLogsScreen = true
|
||||||
},
|
},
|
||||||
|
onNavigateToBiometric = {
|
||||||
|
showProfileScreen = false
|
||||||
|
showBiometricScreen = true
|
||||||
|
},
|
||||||
viewModel = profileViewModel,
|
viewModel = profileViewModel,
|
||||||
avatarRepository = avatarRepository,
|
avatarRepository = avatarRepository,
|
||||||
dialogDao = RosettaDatabase.getDatabase(context).dialogDao()
|
dialogDao = RosettaDatabase.getDatabase(context).dialogDao()
|
||||||
@@ -858,5 +865,49 @@ fun MainScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Biometric Enable Screen
|
||||||
|
androidx.compose.animation.AnimatedVisibility(
|
||||||
|
visible = showBiometricScreen,
|
||||||
|
enter = fadeIn(animationSpec = tween(300)),
|
||||||
|
exit = fadeOut(animationSpec = tween(200))
|
||||||
|
) {
|
||||||
|
if (showBiometricScreen) {
|
||||||
|
val biometricManager = remember { com.rosetta.messenger.biometric.BiometricAuthManager(context) }
|
||||||
|
val biometricPrefs = remember { com.rosetta.messenger.biometric.BiometricPreferences(context) }
|
||||||
|
val activity = context as? FragmentActivity
|
||||||
|
|
||||||
|
BiometricEnableScreen(
|
||||||
|
isDarkTheme = isDarkTheme,
|
||||||
|
onBack = {
|
||||||
|
showBiometricScreen = false
|
||||||
|
showProfileScreen = true
|
||||||
|
},
|
||||||
|
onEnable = { password, onSuccess, onError ->
|
||||||
|
if (activity == null) {
|
||||||
|
onError("Activity not available")
|
||||||
|
return@BiometricEnableScreen
|
||||||
|
}
|
||||||
|
|
||||||
|
biometricManager.encryptPassword(
|
||||||
|
activity = activity,
|
||||||
|
password = password,
|
||||||
|
onSuccess = { encryptedPassword ->
|
||||||
|
mainScreenScope.launch {
|
||||||
|
biometricPrefs.saveEncryptedPassword(accountPublicKey, encryptedPassword)
|
||||||
|
biometricPrefs.enableBiometric()
|
||||||
|
onSuccess()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError = { error -> onError(error) },
|
||||||
|
onCancel = {
|
||||||
|
showBiometricScreen = false
|
||||||
|
showProfileScreen = true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,17 +106,25 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
* Инициализация с текущим аккаунтом
|
* Инициализация с текущим аккаунтом
|
||||||
*/
|
*/
|
||||||
fun initialize(publicKey: String, privateKey: String) {
|
fun initialize(publicKey: String, privateKey: String) {
|
||||||
|
val start = System.currentTimeMillis()
|
||||||
|
android.util.Log.d("MessageRepository", "🔧 initialize started for ${publicKey.take(8)}...")
|
||||||
|
|
||||||
// 🔥 Очищаем кэш запрошенных user info при смене аккаунта
|
// 🔥 Очищаем кэш запрошенных user info при смене аккаунта
|
||||||
if (currentAccount != publicKey) {
|
if (currentAccount != publicKey) {
|
||||||
requestedUserInfoKeys.clear()
|
requestedUserInfoKeys.clear()
|
||||||
|
android.util.Log.d("MessageRepository", "🔧 Cleared user info cache (account changed)")
|
||||||
}
|
}
|
||||||
|
|
||||||
currentAccount = publicKey
|
currentAccount = publicKey
|
||||||
currentPrivateKey = privateKey
|
currentPrivateKey = privateKey
|
||||||
|
|
||||||
|
android.util.Log.d("MessageRepository", "🔧 initialize completed in ${System.currentTimeMillis() - start}ms (launching dialogs flow in background)")
|
||||||
|
|
||||||
// Загрузка диалогов
|
// Загрузка диалогов
|
||||||
scope.launch {
|
scope.launch {
|
||||||
|
android.util.Log.d("MessageRepository", "📂 Starting dialogs flow collection...")
|
||||||
dialogDao.getDialogsFlow(publicKey).collect { entities ->
|
dialogDao.getDialogsFlow(publicKey).collect { entities ->
|
||||||
|
android.util.Log.d("MessageRepository", "📂 Got ${entities.size} dialogs from DB")
|
||||||
_dialogs.value = entities.map { it.toDialog() }
|
_dialogs.value = entities.map { it.toDialog() }
|
||||||
|
|
||||||
// 🔥 Запрашиваем информацию о пользователях, у которых нет имени
|
// 🔥 Запрашиваем информацию о пользователях, у которых нет имени
|
||||||
|
|||||||
@@ -80,7 +80,10 @@ object ProtocolManager {
|
|||||||
* Должен вызываться после авторизации пользователя
|
* Должен вызываться после авторизации пользователя
|
||||||
*/
|
*/
|
||||||
fun initializeAccount(publicKey: String, privateKey: String) {
|
fun initializeAccount(publicKey: String, privateKey: String) {
|
||||||
|
val start = System.currentTimeMillis()
|
||||||
|
android.util.Log.d("ProtocolManager", "🔧 initializeAccount started")
|
||||||
messageRepository?.initialize(publicKey, privateKey)
|
messageRepository?.initialize(publicKey, privateKey)
|
||||||
|
android.util.Log.d("ProtocolManager", "🔧 initializeAccount completed in ${System.currentTimeMillis() - start}ms")
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -73,34 +73,53 @@ private suspend fun performUnlock(
|
|||||||
onError: (String) -> Unit,
|
onError: (String) -> Unit,
|
||||||
onSuccess: (DecryptedAccount) -> Unit
|
onSuccess: (DecryptedAccount) -> Unit
|
||||||
) {
|
) {
|
||||||
|
val TAG = "UnlockScreen"
|
||||||
|
val totalStart = System.currentTimeMillis()
|
||||||
|
|
||||||
if (selectedAccount == null) {
|
if (selectedAccount == null) {
|
||||||
onError("Please select an account")
|
onError("Please select an account")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
onUnlocking(true)
|
onUnlocking(true)
|
||||||
|
android.util.Log.d(TAG, "🔓 Starting unlock for account: ${selectedAccount.name}")
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val account = selectedAccount.encryptedAccount
|
val account = selectedAccount.encryptedAccount
|
||||||
|
|
||||||
// Try to decrypt
|
// Try to decrypt private key
|
||||||
|
val decryptStart = System.currentTimeMillis()
|
||||||
|
android.util.Log.d(TAG, "🔐 Decrypting private key (PBKDF2)...")
|
||||||
val decryptedPrivateKey = CryptoManager.decryptWithPassword(
|
val decryptedPrivateKey = CryptoManager.decryptWithPassword(
|
||||||
account.encryptedPrivateKey,
|
account.encryptedPrivateKey,
|
||||||
password
|
password
|
||||||
)
|
)
|
||||||
|
val decryptPrivateKeyTime = System.currentTimeMillis() - decryptStart
|
||||||
|
android.util.Log.d(TAG, "🔐 Private key decrypted in ${decryptPrivateKeyTime}ms")
|
||||||
|
|
||||||
if (decryptedPrivateKey == null) {
|
if (decryptedPrivateKey == null) {
|
||||||
|
android.util.Log.w(TAG, "❌ Decryption failed - incorrect password")
|
||||||
onError("Incorrect password")
|
onError("Incorrect password")
|
||||||
onUnlocking(false)
|
onUnlocking(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Decrypt seed phrase
|
||||||
|
val seedStart = System.currentTimeMillis()
|
||||||
|
android.util.Log.d(TAG, "🌱 Decrypting seed phrase...")
|
||||||
val decryptedSeedPhrase = CryptoManager.decryptWithPassword(
|
val decryptedSeedPhrase = CryptoManager.decryptWithPassword(
|
||||||
account.encryptedSeedPhrase,
|
account.encryptedSeedPhrase,
|
||||||
password
|
password
|
||||||
)?.split(" ") ?: emptyList()
|
)?.split(" ") ?: emptyList()
|
||||||
|
val seedTime = System.currentTimeMillis() - seedStart
|
||||||
|
android.util.Log.d(TAG, "🌱 Seed phrase decrypted in ${seedTime}ms")
|
||||||
|
|
||||||
|
// Generate private key hash
|
||||||
|
val hashStart = System.currentTimeMillis()
|
||||||
|
android.util.Log.d(TAG, "🔑 Generating private key hash...")
|
||||||
val privateKeyHash = CryptoManager.generatePrivateKeyHash(decryptedPrivateKey)
|
val privateKeyHash = CryptoManager.generatePrivateKeyHash(decryptedPrivateKey)
|
||||||
|
val hashTime = System.currentTimeMillis() - hashStart
|
||||||
|
android.util.Log.d(TAG, "🔑 Hash generated in ${hashTime}ms")
|
||||||
|
|
||||||
val decryptedAccount = DecryptedAccount(
|
val decryptedAccount = DecryptedAccount(
|
||||||
publicKey = account.publicKey,
|
publicKey = account.publicKey,
|
||||||
@@ -110,8 +129,9 @@ private suspend fun performUnlock(
|
|||||||
name = account.name
|
name = account.name
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Connect to server
|
||||||
// Connect to server and authenticate
|
val connectStart = System.currentTimeMillis()
|
||||||
|
android.util.Log.d(TAG, "🌐 Connecting to server...")
|
||||||
ProtocolManager.connect()
|
ProtocolManager.connect()
|
||||||
|
|
||||||
// Wait for websocket connection
|
// Wait for websocket connection
|
||||||
@@ -120,8 +140,11 @@ private suspend fun performUnlock(
|
|||||||
kotlinx.coroutines.delay(100)
|
kotlinx.coroutines.delay(100)
|
||||||
waitAttempts++
|
waitAttempts++
|
||||||
}
|
}
|
||||||
|
val connectTime = System.currentTimeMillis() - connectStart
|
||||||
|
android.util.Log.d(TAG, "🌐 Connected in ${connectTime}ms (attempts: $waitAttempts)")
|
||||||
|
|
||||||
if (ProtocolManager.state.value == ProtocolState.DISCONNECTED) {
|
if (ProtocolManager.state.value == ProtocolState.DISCONNECTED) {
|
||||||
|
android.util.Log.e(TAG, "❌ Connection failed after $waitAttempts attempts")
|
||||||
onError("Failed to connect to server")
|
onError("Failed to connect to server")
|
||||||
onUnlocking(false)
|
onUnlocking(false)
|
||||||
return
|
return
|
||||||
@@ -129,12 +152,22 @@ private suspend fun performUnlock(
|
|||||||
|
|
||||||
kotlinx.coroutines.delay(300)
|
kotlinx.coroutines.delay(300)
|
||||||
|
|
||||||
|
// Authenticate
|
||||||
|
val authStart = System.currentTimeMillis()
|
||||||
|
android.util.Log.d(TAG, "🔒 Authenticating...")
|
||||||
ProtocolManager.authenticate(account.publicKey, privateKeyHash)
|
ProtocolManager.authenticate(account.publicKey, privateKeyHash)
|
||||||
|
val authTime = System.currentTimeMillis() - authStart
|
||||||
|
android.util.Log.d(TAG, "🔒 Auth request sent in ${authTime}ms")
|
||||||
|
|
||||||
accountManager.setCurrentAccount(account.publicKey)
|
accountManager.setCurrentAccount(account.publicKey)
|
||||||
|
|
||||||
|
val totalTime = System.currentTimeMillis() - totalStart
|
||||||
|
android.util.Log.d(TAG, "✅ UNLOCK COMPLETE in ${totalTime}ms")
|
||||||
|
android.util.Log.d(TAG, " 📊 Breakdown: privateKey=${decryptPrivateKeyTime}ms, seed=${seedTime}ms, hash=${hashTime}ms, connect=${connectTime}ms, auth=${authTime}ms")
|
||||||
|
|
||||||
onSuccess(decryptedAccount)
|
onSuccess(decryptedAccount)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e(TAG, "❌ Unlock failed: ${e.message}", e)
|
||||||
onError("Failed to unlock: ${e.message}")
|
onError("Failed to unlock: ${e.message}")
|
||||||
onUnlocking(false)
|
onUnlocking(false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -294,7 +294,8 @@ fun ChatDetailScreen(
|
|||||||
val isOnline by viewModel.opponentOnline.collectAsState()
|
val isOnline by viewModel.opponentOnline.collectAsState()
|
||||||
val isLoading by viewModel.isLoading.collectAsState() // 🔥 Для скелетона
|
val isLoading by viewModel.isLoading.collectAsState() // 🔥 Для скелетона
|
||||||
|
|
||||||
// 🔥 Reply/Forward state
|
|
||||||
|
// <20>🔥 Reply/Forward state
|
||||||
val replyMessages by viewModel.replyMessages.collectAsState()
|
val replyMessages by viewModel.replyMessages.collectAsState()
|
||||||
val hasReply = replyMessages.isNotEmpty()
|
val hasReply = replyMessages.isNotEmpty()
|
||||||
val isForwardMode by viewModel.isForwardMode.collectAsState()
|
val isForwardMode by viewModel.isForwardMode.collectAsState()
|
||||||
|
|||||||
@@ -251,12 +251,21 @@ fun ChatsListScreen(
|
|||||||
// Load dialogs when account is available
|
// Load dialogs when account is available
|
||||||
LaunchedEffect(accountPublicKey, accountPrivateKey) {
|
LaunchedEffect(accountPublicKey, accountPrivateKey) {
|
||||||
if (accountPublicKey.isNotEmpty() && accountPrivateKey.isNotEmpty()) {
|
if (accountPublicKey.isNotEmpty() && accountPrivateKey.isNotEmpty()) {
|
||||||
|
val launchStart = System.currentTimeMillis()
|
||||||
|
android.util.Log.d("ChatsListScreen", "🚀 LaunchedEffect started")
|
||||||
|
|
||||||
chatsViewModel.setAccount(accountPublicKey, accountPrivateKey)
|
chatsViewModel.setAccount(accountPublicKey, accountPrivateKey)
|
||||||
|
android.util.Log.d("ChatsListScreen", "📋 setAccount took ${System.currentTimeMillis() - launchStart}ms")
|
||||||
|
|
||||||
// Устанавливаем аккаунт для RecentSearchesManager
|
// Устанавливаем аккаунт для RecentSearchesManager
|
||||||
RecentSearchesManager.setAccount(accountPublicKey)
|
RecentSearchesManager.setAccount(accountPublicKey)
|
||||||
|
|
||||||
// 🔥 КРИТИЧНО: Инициализируем MessageRepository для обработки входящих
|
// 🔥 КРИТИЧНО: Инициализируем MessageRepository для обработки входящих
|
||||||
// сообщений
|
// сообщений
|
||||||
|
val initStart = System.currentTimeMillis()
|
||||||
ProtocolManager.initializeAccount(accountPublicKey, accountPrivateKey)
|
ProtocolManager.initializeAccount(accountPublicKey, accountPrivateKey)
|
||||||
|
android.util.Log.d("ChatsListScreen", "🌐 initializeAccount took ${System.currentTimeMillis() - initStart}ms")
|
||||||
|
android.util.Log.d("ChatsListScreen", "✅ Total LaunchedEffect: ${System.currentTimeMillis() - launchStart}ms")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -105,11 +105,17 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
|||||||
private val _isLoading = MutableStateFlow(false)
|
private val _isLoading = MutableStateFlow(false)
|
||||||
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
|
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
|
||||||
|
|
||||||
|
private val TAG = "ChatsListVM"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Установить текущий аккаунт и загрузить диалоги
|
* Установить текущий аккаунт и загрузить диалоги
|
||||||
*/
|
*/
|
||||||
fun setAccount(publicKey: String, privateKey: String) {
|
fun setAccount(publicKey: String, privateKey: String) {
|
||||||
|
val setAccountStart = System.currentTimeMillis()
|
||||||
|
android.util.Log.d(TAG, "📋 setAccount called for: ${publicKey.take(8)}...")
|
||||||
|
|
||||||
if (currentAccount == publicKey) {
|
if (currentAccount == publicKey) {
|
||||||
|
android.util.Log.d(TAG, "📋 Account already set, skipping")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,11 +125,15 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
|||||||
currentAccount = publicKey
|
currentAccount = publicKey
|
||||||
currentPrivateKey = privateKey
|
currentPrivateKey = privateKey
|
||||||
|
|
||||||
|
android.util.Log.d(TAG, "📋 Starting dialogs subscription...")
|
||||||
|
|
||||||
// Подписываемся на обычные диалоги
|
// Подписываемся на обычные диалоги
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
dialogDao.getDialogsFlow(publicKey)
|
dialogDao.getDialogsFlow(publicKey)
|
||||||
.flowOn(Dispatchers.IO) // 🚀 Flow работает на IO
|
.flowOn(Dispatchers.IO) // 🚀 Flow работает на IO
|
||||||
.map { dialogsList ->
|
.map { dialogsList ->
|
||||||
|
val mapStart = System.currentTimeMillis()
|
||||||
|
android.util.Log.d(TAG, "🔄 Processing ${dialogsList.size} dialogs...")
|
||||||
// <20> ОПТИМИЗАЦИЯ: Параллельная расшифровка всех сообщений
|
// <20> ОПТИМИЗАЦИЯ: Параллельная расшифровка всех сообщений
|
||||||
withContext(Dispatchers.Default) {
|
withContext(Dispatchers.Default) {
|
||||||
dialogsList.map { dialog ->
|
dialogsList.map { dialog ->
|
||||||
@@ -194,10 +204,14 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}.awaitAll()
|
}.awaitAll()
|
||||||
|
}.also {
|
||||||
|
val mapTime = System.currentTimeMillis() - mapStart
|
||||||
|
android.util.Log.d(TAG, "🔄 Dialogs processed in ${mapTime}ms (${dialogsList.size} items)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки
|
.distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки
|
||||||
.collect { decryptedDialogs ->
|
.collect { decryptedDialogs ->
|
||||||
|
android.util.Log.d(TAG, "✅ Dialogs collected: ${decryptedDialogs.size} items")
|
||||||
_dialogs.value = decryptedDialogs
|
_dialogs.value = decryptedDialogs
|
||||||
|
|
||||||
// 🟢 Подписываемся на онлайн-статусы всех собеседников
|
// 🟢 Подписываемся на онлайн-статусы всех собеседников
|
||||||
|
|||||||
@@ -704,43 +704,43 @@ fun MessageBubble(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
} else if (!hasOnlyMedia && !hasImageWithCaption && message.text.isNotEmpty()) {
|
} else if (!hasOnlyMedia && !hasImageWithCaption && message.text.isNotEmpty()) {
|
||||||
// Telegram-style: текст + время с автоматическим переносом
|
// Telegram-style: текст + время с автоматическим переносом
|
||||||
TelegramStyleMessageContent(
|
TelegramStyleMessageContent(
|
||||||
textContent = {
|
textContent = {
|
||||||
AppleEmojiText(
|
AppleEmojiText(
|
||||||
text = message.text,
|
text = message.text,
|
||||||
color = textColor,
|
color = textColor,
|
||||||
fontSize = 17.sp,
|
fontSize = 17.sp,
|
||||||
linkColor = linkColor
|
linkColor = linkColor
|
||||||
)
|
|
||||||
},
|
|
||||||
timeContent = {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(2.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = timeFormat.format(message.timestamp),
|
|
||||||
color = timeColor,
|
|
||||||
fontSize = 11.sp,
|
|
||||||
fontStyle =
|
|
||||||
androidx.compose.ui.text.font.FontStyle.Italic
|
|
||||||
)
|
)
|
||||||
if (message.isOutgoing) {
|
},
|
||||||
val displayStatus =
|
timeContent = {
|
||||||
if (isSavedMessages) MessageStatus.READ
|
Row(
|
||||||
else message.status
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
AnimatedMessageStatus(
|
horizontalArrangement = Arrangement.spacedBy(2.dp)
|
||||||
status = displayStatus,
|
) {
|
||||||
timeColor = timeColor,
|
Text(
|
||||||
timestamp = message.timestamp.time,
|
text = timeFormat.format(message.timestamp),
|
||||||
onRetry = onRetry,
|
color = timeColor,
|
||||||
onDelete = onDelete
|
fontSize = 11.sp,
|
||||||
|
fontStyle =
|
||||||
|
androidx.compose.ui.text.font.FontStyle.Italic
|
||||||
)
|
)
|
||||||
|
if (message.isOutgoing) {
|
||||||
|
val displayStatus =
|
||||||
|
if (isSavedMessages) MessageStatus.READ
|
||||||
|
else message.status
|
||||||
|
AnimatedMessageStatus(
|
||||||
|
status = displayStatus,
|
||||||
|
timeColor = timeColor,
|
||||||
|
timestamp = message.timestamp.time,
|
||||||
|
onRetry = onRetry,
|
||||||
|
onDelete = onDelete
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,6 +116,15 @@ fun MediaPickerBottomSheet(
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
|
val keyboardView = LocalView.current
|
||||||
|
|
||||||
|
// Function to hide keyboard
|
||||||
|
fun hideKeyboard() {
|
||||||
|
focusManager.clearFocus()
|
||||||
|
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||||
|
imm.hideSoftInputFromWindow(keyboardView.windowToken, 0)
|
||||||
|
}
|
||||||
|
|
||||||
// Media items from gallery
|
// Media items from gallery
|
||||||
var mediaItems by remember { mutableStateOf<List<MediaItem>>(emptyList()) }
|
var mediaItems by remember { mutableStateOf<List<MediaItem>>(emptyList()) }
|
||||||
@@ -496,6 +505,7 @@ fun MediaPickerBottomSheet(
|
|||||||
QuickActionsRow(
|
QuickActionsRow(
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
onCameraClick = {
|
onCameraClick = {
|
||||||
|
hideKeyboard()
|
||||||
animatedClose()
|
animatedClose()
|
||||||
onOpenCamera()
|
onOpenCamera()
|
||||||
},
|
},
|
||||||
@@ -578,6 +588,7 @@ fun MediaPickerBottomSheet(
|
|||||||
mediaItems = mediaItems,
|
mediaItems = mediaItems,
|
||||||
selectedItems = selectedItems,
|
selectedItems = selectedItems,
|
||||||
onCameraClick = {
|
onCameraClick = {
|
||||||
|
hideKeyboard()
|
||||||
animatedClose()
|
animatedClose()
|
||||||
onOpenCamera()
|
onOpenCamera()
|
||||||
},
|
},
|
||||||
@@ -1222,11 +1233,11 @@ private fun MediaGridItem(
|
|||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
Text(
|
Icon(
|
||||||
text = "$selectionIndex",
|
imageVector = TablerIcons.Check,
|
||||||
color = Color.White,
|
contentDescription = null,
|
||||||
fontSize = 12.sp,
|
tint = Color.White,
|
||||||
fontWeight = FontWeight.Bold
|
modifier = Modifier.size(16.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import androidx.compose.foundation.background
|
|||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import compose.icons.TablerIcons
|
import compose.icons.TablerIcons
|
||||||
import compose.icons.tablericons.*
|
import compose.icons.tablericons.*
|
||||||
@@ -16,9 +17,11 @@ import androidx.compose.runtime.snapshotFlow
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.platform.LocalFocusManager
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
@@ -30,6 +33,8 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import app.rosette.android.ui.keyboard.AnimatedKeyboardTransition
|
import app.rosette.android.ui.keyboard.AnimatedKeyboardTransition
|
||||||
import app.rosette.android.ui.keyboard.KeyboardTransitionCoordinator
|
import app.rosette.android.ui.keyboard.KeyboardTransitionCoordinator
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
import coil.request.ImageRequest
|
||||||
import com.rosetta.messenger.network.AttachmentType
|
import com.rosetta.messenger.network.AttachmentType
|
||||||
import com.rosetta.messenger.ui.components.AppleEmojiTextField
|
import com.rosetta.messenger.ui.components.AppleEmojiTextField
|
||||||
import com.rosetta.messenger.ui.components.OptimizedEmojiPicker
|
import com.rosetta.messenger.ui.components.OptimizedEmojiPicker
|
||||||
@@ -48,6 +53,7 @@ import kotlinx.coroutines.launch
|
|||||||
* Telegram UX rules:
|
* Telegram UX rules:
|
||||||
* 1. Input always linked to keyboard (imePadding)
|
* 1. Input always linked to keyboard (imePadding)
|
||||||
* 2. Last message always visible
|
* 2. Last message always visible
|
||||||
|
|
||||||
* 3. No layout jumps
|
* 3. No layout jumps
|
||||||
* 4. After sending: input clears, keyboard stays open
|
* 4. After sending: input clears, keyboard stays open
|
||||||
* 5. Input grows upward for multi-line (up to 6 lines)
|
* 5. Input grows upward for multi-line (up to 6 lines)
|
||||||
|
|||||||
@@ -0,0 +1,430 @@
|
|||||||
|
package com.rosetta.messenger.ui.settings
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.view.inputmethod.InputMethodManager
|
||||||
|
import androidx.compose.animation.*
|
||||||
|
import androidx.compose.animation.core.*
|
||||||
|
import androidx.compose.foundation.*
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
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.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
|
import androidx.compose.ui.platform.LocalView
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
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.airbnb.lottie.compose.*
|
||||||
|
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
||||||
|
import compose.icons.TablerIcons
|
||||||
|
import compose.icons.tablericons.*
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
|
// Auth colors
|
||||||
|
private val AuthBackground = Color(0xFF1A1A1A)
|
||||||
|
private val AuthBackgroundLight = Color(0xFFFFFFFF)
|
||||||
|
private val AuthSurface = Color(0xFF2C2C2E)
|
||||||
|
private val AuthSurfaceLight = Color(0xFFF2F2F7)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Biometric Enable Screen - структура как в auth flow
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun BiometricEnableScreen(
|
||||||
|
isDarkTheme: Boolean,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onEnable: (password: String, onSuccess: () -> Unit, onError: (String) -> Unit) -> Unit
|
||||||
|
) {
|
||||||
|
// Theme animation
|
||||||
|
val themeAnimSpec = tween<Color>(durationMillis = 500, easing = CubicBezierEasing(0.4f, 0f, 0.2f, 1f))
|
||||||
|
val backgroundColor by animateColorAsState(
|
||||||
|
if (isDarkTheme) AuthBackground else AuthBackgroundLight,
|
||||||
|
animationSpec = themeAnimSpec,
|
||||||
|
label = "bg"
|
||||||
|
)
|
||||||
|
val textColor by animateColorAsState(
|
||||||
|
if (isDarkTheme) Color.White else Color.Black,
|
||||||
|
animationSpec = themeAnimSpec,
|
||||||
|
label = "text"
|
||||||
|
)
|
||||||
|
val secondaryTextColor by animateColorAsState(
|
||||||
|
if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666),
|
||||||
|
animationSpec = themeAnimSpec,
|
||||||
|
label = "secondary"
|
||||||
|
)
|
||||||
|
val cardColor by animateColorAsState(
|
||||||
|
if (isDarkTheme) AuthSurface else AuthSurfaceLight,
|
||||||
|
animationSpec = themeAnimSpec,
|
||||||
|
label = "card"
|
||||||
|
)
|
||||||
|
val errorRed = Color(0xFFFF3B30)
|
||||||
|
|
||||||
|
// State
|
||||||
|
var password by remember { mutableStateOf("") }
|
||||||
|
var passwordVisible by remember { mutableStateOf(false) }
|
||||||
|
var passwordError by remember { mutableStateOf<String?>(null) }
|
||||||
|
var isLoading by remember { mutableStateOf(false) }
|
||||||
|
var showSuccess by remember { mutableStateOf(false) }
|
||||||
|
var visible by remember { mutableStateOf(false) }
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
|
val context = LocalContext.current
|
||||||
|
val view = LocalView.current
|
||||||
|
|
||||||
|
// Function to hide keyboard
|
||||||
|
fun hideKeyboard() {
|
||||||
|
focusManager.clearFocus()
|
||||||
|
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||||
|
imm.hideSoftInputFromWindow(view.windowToken, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track keyboard visibility
|
||||||
|
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) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lottie animation - одноразовая
|
||||||
|
val lockComposition by rememberLottieComposition(
|
||||||
|
LottieCompositionSpec.Asset("lottie/lock.json")
|
||||||
|
)
|
||||||
|
val lockProgress by animateLottieCompositionAsState(
|
||||||
|
composition = lockComposition,
|
||||||
|
iterations = 1,
|
||||||
|
speed = 0.8f
|
||||||
|
)
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) { visible = true }
|
||||||
|
|
||||||
|
// Handle success - close screen after delay
|
||||||
|
LaunchedEffect(showSuccess) {
|
||||||
|
if (showSuccess) {
|
||||||
|
delay(1500)
|
||||||
|
onBack()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Animated sizes
|
||||||
|
val lottieSize by animateDpAsState(
|
||||||
|
targetValue = if (isKeyboardVisible) 80.dp else 160.dp,
|
||||||
|
animationSpec = tween(300, easing = FastOutSlowInEasing),
|
||||||
|
label = "lottie"
|
||||||
|
)
|
||||||
|
|
||||||
|
Box(modifier = Modifier.fillMaxSize().background(backgroundColor).navigationBarsPadding()) {
|
||||||
|
Column(modifier = Modifier.fillMaxSize().statusBarsPadding()) {
|
||||||
|
// Top Bar - как в auth flow
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
IconButton(onClick = onBack, enabled = !isLoading) {
|
||||||
|
Icon(
|
||||||
|
imageVector = TablerIcons.ArrowLeft,
|
||||||
|
contentDescription = "Back",
|
||||||
|
tint = textColor.copy(alpha = 0.6f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
Text(
|
||||||
|
text = "Biometric",
|
||||||
|
fontSize = 18.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = textColor
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
Spacer(modifier = Modifier.width(48.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content
|
||||||
|
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))
|
||||||
|
|
||||||
|
// Lottie Animation
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = visible,
|
||||||
|
enter = fadeIn(tween(250)) + scaleIn(
|
||||||
|
initialScale = 0.5f,
|
||||||
|
animationSpec = tween(250, easing = FastOutSlowInEasing)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.size(lottieSize),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
if (lockComposition != null) {
|
||||||
|
LottieAnimation(
|
||||||
|
composition = lockComposition,
|
||||||
|
progress = { lockProgress },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.graphicsLayer {
|
||||||
|
compositingStrategy = androidx.compose.ui.graphics.CompositingStrategy.Offscreen
|
||||||
|
},
|
||||||
|
maintainOriginalImageBounds = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(if (isKeyboardVisible) 12.dp else 24.dp))
|
||||||
|
|
||||||
|
// Title
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = visible,
|
||||||
|
enter = fadeIn(tween(400, delayMillis = 100))
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = if (showSuccess) "Biometric Enabled!" else "Enable Biometric",
|
||||||
|
fontSize = if (isKeyboardVisible) 20.sp else 24.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = if (showSuccess) PrimaryBlue else textColor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(if (isKeyboardVisible) 6.dp else 8.dp))
|
||||||
|
|
||||||
|
// Subtitle
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = visible && !showSuccess,
|
||||||
|
enter = fadeIn(tween(500, delayMillis = 200)),
|
||||||
|
exit = fadeOut(tween(200))
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Use Face ID or Touch ID to quickly\nunlock your account.",
|
||||||
|
fontSize = if (isKeyboardVisible) 12.sp else 14.sp,
|
||||||
|
color = secondaryTextColor,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
lineHeight = if (isKeyboardVisible) 16.sp else 20.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success message
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = showSuccess,
|
||||||
|
enter = fadeIn(tween(300)) + expandVertically()
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "You can now unlock with biometrics",
|
||||||
|
fontSize = 14.sp,
|
||||||
|
color = secondaryTextColor,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(if (isKeyboardVisible) 16.dp else 32.dp))
|
||||||
|
|
||||||
|
// Password Field
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = visible && !showSuccess,
|
||||||
|
enter = fadeIn(tween(400, delayMillis = 300)),
|
||||||
|
exit = fadeOut(tween(200))
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = password,
|
||||||
|
onValueChange = {
|
||||||
|
password = it
|
||||||
|
passwordError = null
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
placeholder = {
|
||||||
|
Text(
|
||||||
|
"Enter your password",
|
||||||
|
color = secondaryTextColor.copy(alpha = 0.6f)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = TablerIcons.Lock,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = if (passwordError != null) errorRed else secondaryTextColor,
|
||||||
|
modifier = Modifier.size(22.dp)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
trailingIcon = {
|
||||||
|
IconButton(onClick = { passwordVisible = !passwordVisible }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (passwordVisible) TablerIcons.EyeOff else TablerIcons.Eye,
|
||||||
|
contentDescription = if (passwordVisible) "Hide" else "Show",
|
||||||
|
tint = secondaryTextColor,
|
||||||
|
modifier = Modifier.size(22.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
|
||||||
|
isError = passwordError != null,
|
||||||
|
singleLine = true,
|
||||||
|
shape = RoundedCornerShape(14.dp),
|
||||||
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
|
focusedBorderColor = PrimaryBlue,
|
||||||
|
unfocusedBorderColor = if (isDarkTheme) Color(0xFF48484A) else Color(0xFFD1D1D6),
|
||||||
|
focusedContainerColor = cardColor,
|
||||||
|
unfocusedContainerColor = cardColor,
|
||||||
|
focusedTextColor = textColor,
|
||||||
|
unfocusedTextColor = textColor,
|
||||||
|
cursorColor = PrimaryBlue,
|
||||||
|
errorBorderColor = errorRed,
|
||||||
|
errorContainerColor = cardColor
|
||||||
|
),
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Password,
|
||||||
|
imeAction = ImeAction.Done
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Error message
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = passwordError != null,
|
||||||
|
enter = fadeIn() + expandVertically(),
|
||||||
|
exit = fadeOut() + shrinkVertically()
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(top = 8.dp, start = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = TablerIcons.AlertCircle,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = errorRed,
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
|
Text(
|
||||||
|
text = passwordError ?: "",
|
||||||
|
color = errorRed,
|
||||||
|
fontSize = 13.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
// Enable button
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = visible && !showSuccess,
|
||||||
|
enter = fadeIn(tween(400, delayMillis = 400)),
|
||||||
|
exit = fadeOut(tween(200))
|
||||||
|
) {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
if (password.isEmpty()) {
|
||||||
|
passwordError = "Please enter your password"
|
||||||
|
return@Button
|
||||||
|
}
|
||||||
|
|
||||||
|
hideKeyboard()
|
||||||
|
isLoading = true
|
||||||
|
onEnable(
|
||||||
|
password,
|
||||||
|
{
|
||||||
|
isLoading = false
|
||||||
|
showSuccess = true
|
||||||
|
},
|
||||||
|
{ error ->
|
||||||
|
isLoading = false
|
||||||
|
passwordError = error
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
enabled = password.isNotEmpty() && !isLoading,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(54.dp),
|
||||||
|
shape = RoundedCornerShape(14.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = PrimaryBlue,
|
||||||
|
contentColor = Color.White,
|
||||||
|
disabledContainerColor = PrimaryBlue.copy(alpha = 0.4f),
|
||||||
|
disabledContentColor = Color.White.copy(alpha = 0.6f)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
if (isLoading) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(22.dp),
|
||||||
|
color = Color.White,
|
||||||
|
strokeWidth = 2.dp
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Icon(
|
||||||
|
imageVector = TablerIcons.Fingerprint,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(22.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(10.dp))
|
||||||
|
Text(
|
||||||
|
text = "Enable Biometric",
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
|
||||||
|
// Info - hide when keyboard visible
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = visible && !isKeyboardVisible && !showSuccess,
|
||||||
|
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(
|
||||||
|
imageVector = TablerIcons.ShieldLock,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = PrimaryBlue,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
Text(
|
||||||
|
text = "Your password is encrypted and stored securely on this device only.",
|
||||||
|
fontSize = 13.sp,
|
||||||
|
color = secondaryTextColor,
|
||||||
|
lineHeight = 18.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -191,6 +191,7 @@ fun ProfileScreen(
|
|||||||
onNavigateToSafety: () -> Unit = {},
|
onNavigateToSafety: () -> Unit = {},
|
||||||
onNavigateToLogs: () -> Unit = {},
|
onNavigateToLogs: () -> Unit = {},
|
||||||
onNavigateToCrashLogs: () -> Unit = {},
|
onNavigateToCrashLogs: () -> Unit = {},
|
||||||
|
onNavigateToBiometric: () -> Unit = {},
|
||||||
viewModel: ProfileViewModel = androidx.lifecycle.viewmodel.compose.viewModel(),
|
viewModel: ProfileViewModel = androidx.lifecycle.viewmodel.compose.viewModel(),
|
||||||
avatarRepository: AvatarRepository? = null,
|
avatarRepository: AvatarRepository? = null,
|
||||||
dialogDao: com.rosetta.messenger.database.DialogDao? = null
|
dialogDao: com.rosetta.messenger.database.DialogDao? = null
|
||||||
@@ -198,22 +199,20 @@ fun ProfileScreen(
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val activity = context as? FragmentActivity
|
val activity = context as? FragmentActivity
|
||||||
val biometricManager = remember { BiometricAuthManager(context) }
|
val biometricManager = remember { BiometricAuthManager(context) }
|
||||||
val biometricPrefs = remember { BiometricPreferences(context) }
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
// Biometric state
|
// Biometric availability
|
||||||
var biometricAvailable by remember {
|
var biometricAvailable by remember {
|
||||||
mutableStateOf<BiometricAvailability>(BiometricAvailability.NotAvailable("Checking..."))
|
mutableStateOf<BiometricAvailability>(BiometricAvailability.NotAvailable("Checking..."))
|
||||||
}
|
}
|
||||||
var isBiometricEnabled by remember { mutableStateOf(false) }
|
|
||||||
var showPasswordDialog by remember { mutableStateOf(false) }
|
// Biometric enabled state - read directly to always show current state
|
||||||
var passwordInput by remember { mutableStateOf("") }
|
val biometricPrefs = remember { BiometricPreferences(context) }
|
||||||
var passwordError by remember { mutableStateOf<String?>(null) }
|
val isBiometricEnabled by biometricPrefs.isBiometricEnabled.collectAsState(initial = false)
|
||||||
|
|
||||||
// Check biometric availability
|
// Check biometric availability
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
biometricAvailable = biometricManager.isBiometricAvailable()
|
biometricAvailable = biometricManager.isBiometricAvailable()
|
||||||
isBiometricEnabled = biometricPrefs.isBiometricEnabled.first()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Состояние меню аватара для установки фото профиля
|
// Состояние меню аватара для установки фото профиля
|
||||||
@@ -627,32 +626,17 @@ fun ProfileScreen(
|
|||||||
showDivider = biometricAvailable is BiometricAvailability.Available
|
showDivider = biometricAvailable is BiometricAvailability.Available
|
||||||
)
|
)
|
||||||
|
|
||||||
// Biometric toggle (only show if available)
|
// Biometric settings (only show if available)
|
||||||
if (biometricAvailable is BiometricAvailability.Available && activity != null) {
|
if (biometricAvailable is BiometricAvailability.Available && activity != null) {
|
||||||
TelegramBiometricItem(
|
TelegramSettingsItem(
|
||||||
isEnabled = isBiometricEnabled,
|
icon = TablerIcons.Fingerprint,
|
||||||
onToggle = {
|
title = "Biometric Authentication",
|
||||||
if (isBiometricEnabled) {
|
onClick = {
|
||||||
// Disable biometric
|
onNavigateToBiometric()
|
||||||
scope.launch {
|
|
||||||
biometricPrefs.disableBiometric()
|
|
||||||
biometricPrefs.removeEncryptedPassword(accountPublicKey)
|
|
||||||
isBiometricEnabled = false
|
|
||||||
android.widget.Toast.makeText(
|
|
||||||
context,
|
|
||||||
"Biometric authentication disabled",
|
|
||||||
android.widget.Toast.LENGTH_SHORT
|
|
||||||
)
|
|
||||||
.show()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Enable biometric - show password dialog first
|
|
||||||
passwordInput = ""
|
|
||||||
passwordError = null
|
|
||||||
showPasswordDialog = true
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
isDarkTheme = isDarkTheme
|
isDarkTheme = isDarkTheme,
|
||||||
|
showDivider = false,
|
||||||
|
subtitle = if (isBiometricEnabled) "Enabled" else "Disabled"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -713,96 +697,6 @@ fun ProfileScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Password dialog for biometric setup
|
|
||||||
if (showPasswordDialog && activity != null) {
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = {
|
|
||||||
showPasswordDialog = false
|
|
||||||
passwordInput = ""
|
|
||||||
passwordError = null
|
|
||||||
},
|
|
||||||
title = { Text("Enable Biometric Authentication") },
|
|
||||||
text = {
|
|
||||||
Column {
|
|
||||||
Text("Enter your password to securely save it for biometric unlock:")
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
OutlinedTextField(
|
|
||||||
value = passwordInput,
|
|
||||||
onValueChange = {
|
|
||||||
passwordInput = it
|
|
||||||
passwordError = null
|
|
||||||
},
|
|
||||||
label = { Text("Password") },
|
|
||||||
singleLine = true,
|
|
||||||
visualTransformation =
|
|
||||||
androidx.compose.ui.text.input
|
|
||||||
.PasswordVisualTransformation(),
|
|
||||||
isError = passwordError != null,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
if (passwordError != null) {
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
Text(text = passwordError!!, color = Color.Red, fontSize = 12.sp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(
|
|
||||||
onClick = {
|
|
||||||
if (passwordInput.isEmpty()) {
|
|
||||||
passwordError = "Password cannot be empty"
|
|
||||||
return@TextButton
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to encrypt the password with biometric
|
|
||||||
biometricManager.encryptPassword(
|
|
||||||
activity = activity,
|
|
||||||
password = passwordInput,
|
|
||||||
onSuccess = { encryptedPassword ->
|
|
||||||
scope.launch {
|
|
||||||
// Save encrypted password
|
|
||||||
biometricPrefs.saveEncryptedPassword(
|
|
||||||
accountPublicKey,
|
|
||||||
encryptedPassword
|
|
||||||
)
|
|
||||||
// Enable biometric
|
|
||||||
biometricPrefs.enableBiometric()
|
|
||||||
isBiometricEnabled = true
|
|
||||||
|
|
||||||
showPasswordDialog = false
|
|
||||||
passwordInput = ""
|
|
||||||
passwordError = null
|
|
||||||
|
|
||||||
android.widget.Toast.makeText(
|
|
||||||
context,
|
|
||||||
"Biometric authentication enabled successfully",
|
|
||||||
android.widget.Toast.LENGTH_SHORT
|
|
||||||
)
|
|
||||||
.show()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onError = { error -> passwordError = error },
|
|
||||||
onCancel = {
|
|
||||||
showPasswordDialog = false
|
|
||||||
passwordInput = ""
|
|
||||||
passwordError = null
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
) { Text("Enable") }
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(
|
|
||||||
onClick = {
|
|
||||||
showPasswordDialog = false
|
|
||||||
passwordInput = ""
|
|
||||||
passwordError = null
|
|
||||||
}
|
|
||||||
) { Text("Cancel") }
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 🖼️ Кастомный быстрый Photo Picker
|
// 🖼️ Кастомный быстрый Photo Picker
|
||||||
ProfilePhotoPicker(
|
ProfilePhotoPicker(
|
||||||
isVisible = showPhotoPicker,
|
isVisible = showPhotoPicker,
|
||||||
@@ -1342,9 +1236,11 @@ private fun TelegramSettingsItem(
|
|||||||
title: String,
|
title: String,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
isDarkTheme: Boolean,
|
isDarkTheme: Boolean,
|
||||||
showDivider: Boolean = false
|
showDivider: Boolean = false,
|
||||||
|
subtitle: String? = null
|
||||||
) {
|
) {
|
||||||
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 iconColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
|
val iconColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
|
||||||
val dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE0E0E0)
|
val dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE0E0E0)
|
||||||
|
|
||||||
@@ -1365,7 +1261,13 @@ private fun TelegramSettingsItem(
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.width(20.dp))
|
Spacer(modifier = Modifier.width(20.dp))
|
||||||
|
|
||||||
Text(text = title, fontSize = 16.sp, color = textColor, modifier = Modifier.weight(1f))
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(text = title, fontSize = 16.sp, color = textColor)
|
||||||
|
if (subtitle != null) {
|
||||||
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
|
Text(text = subtitle, fontSize = 13.sp, color = secondaryTextColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showDivider) {
|
if (showDivider) {
|
||||||
|
|||||||
Reference in New Issue
Block a user