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-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
|
||||
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.onboarding.OnboardingScreen
|
||||
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.ProfileScreen
|
||||
import com.rosetta.messenger.ui.settings.SafetyScreen
|
||||
@@ -527,7 +528,8 @@ fun MainScreen(
|
||||
var showBackupScreen by remember { mutableStateOf(false) }
|
||||
var showLogsScreen by remember { mutableStateOf(false) }
|
||||
var showCrashLogsScreen by remember { mutableStateOf(false) }
|
||||
|
||||
var showBiometricScreen by remember { mutableStateOf(false) }
|
||||
|
||||
// ProfileViewModel для логов
|
||||
val profileViewModel: com.rosetta.messenger.ui.settings.ProfileViewModel = androidx.lifecycle.viewmodel.compose.viewModel()
|
||||
val profileState by profileViewModel.state.collectAsState()
|
||||
@@ -553,9 +555,10 @@ fun MainScreen(
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
// Base layer - chats list
|
||||
androidx.compose.animation.AnimatedVisibility(
|
||||
visible = !showBackupScreen && !showSafetyScreen && !showThemeScreen &&
|
||||
!showUpdatesScreen && selectedUser == null && !showSearchScreen &&
|
||||
!showProfileScreen && !showOtherProfileScreen && !showLogsScreen && !showCrashLogsScreen,
|
||||
visible = !showBackupScreen && !showSafetyScreen && !showThemeScreen &&
|
||||
!showUpdatesScreen && selectedUser == null && !showSearchScreen &&
|
||||
!showProfileScreen && !showOtherProfileScreen && !showLogsScreen &&
|
||||
!showCrashLogsScreen && !showBiometricScreen,
|
||||
enter = fadeIn(animationSpec = tween(300)),
|
||||
exit = fadeOut(animationSpec = tween(200))
|
||||
) {
|
||||
@@ -799,6 +802,10 @@ fun MainScreen(
|
||||
showProfileScreen = false
|
||||
showCrashLogsScreen = true
|
||||
},
|
||||
onNavigateToBiometric = {
|
||||
showProfileScreen = false
|
||||
showBiometricScreen = true
|
||||
},
|
||||
viewModel = profileViewModel,
|
||||
avatarRepository = avatarRepository,
|
||||
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) {
|
||||
val start = System.currentTimeMillis()
|
||||
android.util.Log.d("MessageRepository", "🔧 initialize started for ${publicKey.take(8)}...")
|
||||
|
||||
// 🔥 Очищаем кэш запрошенных user info при смене аккаунта
|
||||
if (currentAccount != publicKey) {
|
||||
requestedUserInfoKeys.clear()
|
||||
android.util.Log.d("MessageRepository", "🔧 Cleared user info cache (account changed)")
|
||||
}
|
||||
|
||||
currentAccount = publicKey
|
||||
currentPrivateKey = privateKey
|
||||
|
||||
android.util.Log.d("MessageRepository", "🔧 initialize completed in ${System.currentTimeMillis() - start}ms (launching dialogs flow in background)")
|
||||
|
||||
// Загрузка диалогов
|
||||
scope.launch {
|
||||
android.util.Log.d("MessageRepository", "📂 Starting dialogs flow collection...")
|
||||
dialogDao.getDialogsFlow(publicKey).collect { entities ->
|
||||
android.util.Log.d("MessageRepository", "📂 Got ${entities.size} dialogs from DB")
|
||||
_dialogs.value = entities.map { it.toDialog() }
|
||||
|
||||
// 🔥 Запрашиваем информацию о пользователях, у которых нет имени
|
||||
|
||||
@@ -80,7 +80,10 @@ object ProtocolManager {
|
||||
* Должен вызываться после авторизации пользователя
|
||||
*/
|
||||
fun initializeAccount(publicKey: String, privateKey: String) {
|
||||
val start = System.currentTimeMillis()
|
||||
android.util.Log.d("ProtocolManager", "🔧 initializeAccount started")
|
||||
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,
|
||||
onSuccess: (DecryptedAccount) -> Unit
|
||||
) {
|
||||
val TAG = "UnlockScreen"
|
||||
val totalStart = System.currentTimeMillis()
|
||||
|
||||
if (selectedAccount == null) {
|
||||
onError("Please select an account")
|
||||
return
|
||||
}
|
||||
|
||||
onUnlocking(true)
|
||||
android.util.Log.d(TAG, "🔓 Starting unlock for account: ${selectedAccount.name}")
|
||||
|
||||
try {
|
||||
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(
|
||||
account.encryptedPrivateKey,
|
||||
password
|
||||
)
|
||||
val decryptPrivateKeyTime = System.currentTimeMillis() - decryptStart
|
||||
android.util.Log.d(TAG, "🔐 Private key decrypted in ${decryptPrivateKeyTime}ms")
|
||||
|
||||
if (decryptedPrivateKey == null) {
|
||||
android.util.Log.w(TAG, "❌ Decryption failed - incorrect password")
|
||||
onError("Incorrect password")
|
||||
onUnlocking(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Decrypt seed phrase
|
||||
val seedStart = System.currentTimeMillis()
|
||||
android.util.Log.d(TAG, "🌱 Decrypting seed phrase...")
|
||||
val decryptedSeedPhrase = CryptoManager.decryptWithPassword(
|
||||
account.encryptedSeedPhrase,
|
||||
password
|
||||
)?.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 hashTime = System.currentTimeMillis() - hashStart
|
||||
android.util.Log.d(TAG, "🔑 Hash generated in ${hashTime}ms")
|
||||
|
||||
val decryptedAccount = DecryptedAccount(
|
||||
publicKey = account.publicKey,
|
||||
@@ -110,8 +129,9 @@ private suspend fun performUnlock(
|
||||
name = account.name
|
||||
)
|
||||
|
||||
|
||||
// Connect to server and authenticate
|
||||
// Connect to server
|
||||
val connectStart = System.currentTimeMillis()
|
||||
android.util.Log.d(TAG, "🌐 Connecting to server...")
|
||||
ProtocolManager.connect()
|
||||
|
||||
// Wait for websocket connection
|
||||
@@ -120,8 +140,11 @@ private suspend fun performUnlock(
|
||||
kotlinx.coroutines.delay(100)
|
||||
waitAttempts++
|
||||
}
|
||||
val connectTime = System.currentTimeMillis() - connectStart
|
||||
android.util.Log.d(TAG, "🌐 Connected in ${connectTime}ms (attempts: $waitAttempts)")
|
||||
|
||||
if (ProtocolManager.state.value == ProtocolState.DISCONNECTED) {
|
||||
android.util.Log.e(TAG, "❌ Connection failed after $waitAttempts attempts")
|
||||
onError("Failed to connect to server")
|
||||
onUnlocking(false)
|
||||
return
|
||||
@@ -129,12 +152,22 @@ private suspend fun performUnlock(
|
||||
|
||||
kotlinx.coroutines.delay(300)
|
||||
|
||||
// Authenticate
|
||||
val authStart = System.currentTimeMillis()
|
||||
android.util.Log.d(TAG, "🔒 Authenticating...")
|
||||
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)
|
||||
|
||||
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)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e(TAG, "❌ Unlock failed: ${e.message}", e)
|
||||
onError("Failed to unlock: ${e.message}")
|
||||
onUnlocking(false)
|
||||
}
|
||||
|
||||
@@ -294,7 +294,8 @@ fun ChatDetailScreen(
|
||||
val isOnline by viewModel.opponentOnline.collectAsState()
|
||||
val isLoading by viewModel.isLoading.collectAsState() // 🔥 Для скелетона
|
||||
|
||||
// 🔥 Reply/Forward state
|
||||
|
||||
// <20>🔥 Reply/Forward state
|
||||
val replyMessages by viewModel.replyMessages.collectAsState()
|
||||
val hasReply = replyMessages.isNotEmpty()
|
||||
val isForwardMode by viewModel.isForwardMode.collectAsState()
|
||||
|
||||
@@ -251,12 +251,21 @@ fun ChatsListScreen(
|
||||
// Load dialogs when account is available
|
||||
LaunchedEffect(accountPublicKey, accountPrivateKey) {
|
||||
if (accountPublicKey.isNotEmpty() && accountPrivateKey.isNotEmpty()) {
|
||||
val launchStart = System.currentTimeMillis()
|
||||
android.util.Log.d("ChatsListScreen", "🚀 LaunchedEffect started")
|
||||
|
||||
chatsViewModel.setAccount(accountPublicKey, accountPrivateKey)
|
||||
android.util.Log.d("ChatsListScreen", "📋 setAccount took ${System.currentTimeMillis() - launchStart}ms")
|
||||
|
||||
// Устанавливаем аккаунт для RecentSearchesManager
|
||||
RecentSearchesManager.setAccount(accountPublicKey)
|
||||
|
||||
// 🔥 КРИТИЧНО: Инициализируем MessageRepository для обработки входящих
|
||||
// сообщений
|
||||
val initStart = System.currentTimeMillis()
|
||||
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)
|
||||
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
|
||||
|
||||
private val TAG = "ChatsListVM"
|
||||
|
||||
/**
|
||||
* Установить текущий аккаунт и загрузить диалоги
|
||||
*/
|
||||
fun setAccount(publicKey: String, privateKey: String) {
|
||||
val setAccountStart = System.currentTimeMillis()
|
||||
android.util.Log.d(TAG, "📋 setAccount called for: ${publicKey.take(8)}...")
|
||||
|
||||
if (currentAccount == publicKey) {
|
||||
android.util.Log.d(TAG, "📋 Account already set, skipping")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -119,11 +125,15 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
||||
currentAccount = publicKey
|
||||
currentPrivateKey = privateKey
|
||||
|
||||
android.util.Log.d(TAG, "📋 Starting dialogs subscription...")
|
||||
|
||||
// Подписываемся на обычные диалоги
|
||||
viewModelScope.launch {
|
||||
dialogDao.getDialogsFlow(publicKey)
|
||||
.flowOn(Dispatchers.IO) // 🚀 Flow работает на IO
|
||||
.map { dialogsList ->
|
||||
val mapStart = System.currentTimeMillis()
|
||||
android.util.Log.d(TAG, "🔄 Processing ${dialogsList.size} dialogs...")
|
||||
// <20> ОПТИМИЗАЦИЯ: Параллельная расшифровка всех сообщений
|
||||
withContext(Dispatchers.Default) {
|
||||
dialogsList.map { dialog ->
|
||||
@@ -194,10 +204,14 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
||||
)
|
||||
}
|
||||
}.awaitAll()
|
||||
}.also {
|
||||
val mapTime = System.currentTimeMillis() - mapStart
|
||||
android.util.Log.d(TAG, "🔄 Dialogs processed in ${mapTime}ms (${dialogsList.size} items)")
|
||||
}
|
||||
}
|
||||
.distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки
|
||||
.collect { decryptedDialogs ->
|
||||
android.util.Log.d(TAG, "✅ Dialogs collected: ${decryptedDialogs.size} items")
|
||||
_dialogs.value = decryptedDialogs
|
||||
|
||||
// 🟢 Подписываемся на онлайн-статусы всех собеседников
|
||||
|
||||
@@ -704,43 +704,43 @@ fun MessageBubble(
|
||||
}
|
||||
)
|
||||
} else if (!hasOnlyMedia && !hasImageWithCaption && message.text.isNotEmpty()) {
|
||||
// Telegram-style: текст + время с автоматическим переносом
|
||||
TelegramStyleMessageContent(
|
||||
textContent = {
|
||||
AppleEmojiText(
|
||||
text = message.text,
|
||||
color = textColor,
|
||||
fontSize = 17.sp,
|
||||
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
|
||||
// Telegram-style: текст + время с автоматическим переносом
|
||||
TelegramStyleMessageContent(
|
||||
textContent = {
|
||||
AppleEmojiText(
|
||||
text = message.text,
|
||||
color = textColor,
|
||||
fontSize = 17.sp,
|
||||
linkColor = linkColor
|
||||
)
|
||||
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
|
||||
},
|
||||
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 =
|
||||
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 scope = rememberCoroutineScope()
|
||||
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
|
||||
var mediaItems by remember { mutableStateOf<List<MediaItem>>(emptyList()) }
|
||||
@@ -496,6 +505,7 @@ fun MediaPickerBottomSheet(
|
||||
QuickActionsRow(
|
||||
isDarkTheme = isDarkTheme,
|
||||
onCameraClick = {
|
||||
hideKeyboard()
|
||||
animatedClose()
|
||||
onOpenCamera()
|
||||
},
|
||||
@@ -578,6 +588,7 @@ fun MediaPickerBottomSheet(
|
||||
mediaItems = mediaItems,
|
||||
selectedItems = selectedItems,
|
||||
onCameraClick = {
|
||||
hideKeyboard()
|
||||
animatedClose()
|
||||
onOpenCamera()
|
||||
},
|
||||
@@ -1222,11 +1233,11 @@ private fun MediaGridItem(
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (isSelected) {
|
||||
Text(
|
||||
text = "$selectionIndex",
|
||||
color = Color.White,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
Icon(
|
||||
imageVector = TablerIcons.Check,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.*
|
||||
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.Modifier
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
@@ -30,6 +33,8 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import app.rosette.android.ui.keyboard.AnimatedKeyboardTransition
|
||||
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.ui.components.AppleEmojiTextField
|
||||
import com.rosetta.messenger.ui.components.OptimizedEmojiPicker
|
||||
@@ -48,6 +53,7 @@ import kotlinx.coroutines.launch
|
||||
* Telegram UX rules:
|
||||
* 1. Input always linked to keyboard (imePadding)
|
||||
* 2. Last message always visible
|
||||
|
||||
* 3. No layout jumps
|
||||
* 4. After sending: input clears, keyboard stays open
|
||||
* 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 = {},
|
||||
onNavigateToLogs: () -> Unit = {},
|
||||
onNavigateToCrashLogs: () -> Unit = {},
|
||||
onNavigateToBiometric: () -> Unit = {},
|
||||
viewModel: ProfileViewModel = androidx.lifecycle.viewmodel.compose.viewModel(),
|
||||
avatarRepository: AvatarRepository? = null,
|
||||
dialogDao: com.rosetta.messenger.database.DialogDao? = null
|
||||
@@ -198,22 +199,20 @@ fun ProfileScreen(
|
||||
val context = LocalContext.current
|
||||
val activity = context as? FragmentActivity
|
||||
val biometricManager = remember { BiometricAuthManager(context) }
|
||||
val biometricPrefs = remember { BiometricPreferences(context) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// Biometric state
|
||||
// Biometric availability
|
||||
var biometricAvailable by remember {
|
||||
mutableStateOf<BiometricAvailability>(BiometricAvailability.NotAvailable("Checking..."))
|
||||
}
|
||||
var isBiometricEnabled by remember { mutableStateOf(false) }
|
||||
var showPasswordDialog by remember { mutableStateOf(false) }
|
||||
var passwordInput by remember { mutableStateOf("") }
|
||||
var passwordError by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
// Biometric enabled state - read directly to always show current state
|
||||
val biometricPrefs = remember { BiometricPreferences(context) }
|
||||
val isBiometricEnabled by biometricPrefs.isBiometricEnabled.collectAsState(initial = false)
|
||||
|
||||
// Check biometric availability
|
||||
LaunchedEffect(Unit) {
|
||||
biometricAvailable = biometricManager.isBiometricAvailable()
|
||||
isBiometricEnabled = biometricPrefs.isBiometricEnabled.first()
|
||||
}
|
||||
|
||||
// Состояние меню аватара для установки фото профиля
|
||||
@@ -627,32 +626,17 @@ fun ProfileScreen(
|
||||
showDivider = biometricAvailable is BiometricAvailability.Available
|
||||
)
|
||||
|
||||
// Biometric toggle (only show if available)
|
||||
// Biometric settings (only show if available)
|
||||
if (biometricAvailable is BiometricAvailability.Available && activity != null) {
|
||||
TelegramBiometricItem(
|
||||
isEnabled = isBiometricEnabled,
|
||||
onToggle = {
|
||||
if (isBiometricEnabled) {
|
||||
// Disable biometric
|
||||
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
|
||||
}
|
||||
TelegramSettingsItem(
|
||||
icon = TablerIcons.Fingerprint,
|
||||
title = "Biometric Authentication",
|
||||
onClick = {
|
||||
onNavigateToBiometric()
|
||||
},
|
||||
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
|
||||
ProfilePhotoPicker(
|
||||
isVisible = showPhotoPicker,
|
||||
@@ -1342,9 +1236,11 @@ private fun TelegramSettingsItem(
|
||||
title: String,
|
||||
onClick: () -> Unit,
|
||||
isDarkTheme: Boolean,
|
||||
showDivider: Boolean = false
|
||||
showDivider: Boolean = false,
|
||||
subtitle: String? = null
|
||||
) {
|
||||
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 dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE0E0E0)
|
||||
|
||||
@@ -1365,7 +1261,13 @@ private fun TelegramSettingsItem(
|
||||
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user