diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ceedac7..6f0a2aa 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -48,10 +48,10 @@ android { } } compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 } - kotlinOptions { jvmTarget = "1.8" } + kotlinOptions { jvmTarget = "11" } buildFeatures { compose = true } composeOptions { kotlinCompilerExtensionVersion = "1.5.4" } packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt index ab2a912..746e0a4 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt @@ -1,6 +1,5 @@ package com.rosetta.messenger.ui.settings -import android.app.Activity import android.content.ClipData import android.content.ClipboardManager import android.content.Context @@ -22,13 +21,10 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.material3.* -import compose.icons.TablerIcons -import compose.icons.tablericons.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.shadow import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color @@ -39,17 +35,14 @@ import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity -import com.rosetta.messenger.utils.ImageCropHelper import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.fragment.app.FragmentActivity @@ -57,15 +50,16 @@ import com.rosetta.messenger.biometric.BiometricAuthManager import com.rosetta.messenger.biometric.BiometricAvailability import com.rosetta.messenger.biometric.BiometricPreferences import com.rosetta.messenger.repository.AvatarRepository -import com.rosetta.messenger.utils.AvatarFileManager -import com.rosetta.messenger.ui.components.AvatarImage import com.rosetta.messenger.ui.components.BlurredAvatarBackground +import com.rosetta.messenger.utils.AvatarFileManager +import com.rosetta.messenger.utils.ImageCropHelper +import compose.icons.TablerIcons +import compose.icons.tablericons.* +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import kotlin.math.roundToInt private const val TAG = "ProfileScreen" @@ -105,8 +99,8 @@ data class AvatarColors(val textColor: Color, val backgroundColor: Color) private val avatarColorCache = mutableMapOf() /** - * Определяет, является ли цвет светлым (true) или темным (false) - * Использует формулу relative luminance из WCAG + * Определяет, является ли цвет светлым (true) или темным (false) Использует формулу relative + * luminance из WCAG */ fun isColorLight(color: Color): Boolean { val luminance = 0.299f * color.red + 0.587f * color.green + 0.114f * color.blue @@ -114,16 +108,13 @@ fun isColorLight(color: Color): Boolean { } fun getAvatarColor(name: String, isDarkTheme: Boolean): AvatarColors { - val cacheKey = "${name}_${if (isDarkTheme) "dark" else "light"}" - return avatarColorCache.getOrPut(cacheKey) { - val colors = if (isDarkTheme) avatarColorsDark else avatarColorsLight - val index = - name.hashCode().mod(colors.size).let { - if (it < 0) it + colors.size else it - } - val (textColor, bgColor) = colors[index] - AvatarColors(textColor, bgColor) - } + val cacheKey = "${name}_${if (isDarkTheme) "dark" else "light"}" + return avatarColorCache.getOrPut(cacheKey) { + val colors = if (isDarkTheme) avatarColorsDark else avatarColorsLight + val index = name.hashCode().mod(colors.size).let { if (it < 0) it + colors.size else it } + val (textColor, bgColor) = colors[index] + AvatarColors(textColor, bgColor) + } } fun getInitials(name: String): String { @@ -148,214 +139,240 @@ private val STATUS_BAR_HEIGHT = 24.dp @OptIn(ExperimentalMaterial3Api::class) @Composable fun ProfileScreen( - isDarkTheme: Boolean, - accountName: String, - accountUsername: String, - accountPublicKey: String, - accountPrivateKeyHash: String, - onBack: () -> Unit, - onSaveProfile: (name: String, username: String) -> Unit, - onLogout: () -> Unit, - onNavigateToTheme: () -> Unit = {}, - onNavigateToSafety: () -> Unit = {}, - onNavigateToLogs: () -> Unit = {}, - onNavigateToCrashLogs: () -> Unit = {}, - viewModel: ProfileViewModel = androidx.lifecycle.viewmodel.compose.viewModel(), - avatarRepository: AvatarRepository? = null, - dialogDao: com.rosetta.messenger.database.DialogDao? = null + isDarkTheme: Boolean, + accountName: String, + accountUsername: String, + accountPublicKey: String, + accountPrivateKeyHash: String, + onBack: () -> Unit, + onSaveProfile: (name: String, username: String) -> Unit, + onLogout: () -> Unit, + onNavigateToTheme: () -> Unit = {}, + onNavigateToSafety: () -> Unit = {}, + onNavigateToLogs: () -> Unit = {}, + onNavigateToCrashLogs: () -> Unit = {}, + viewModel: ProfileViewModel = androidx.lifecycle.viewmodel.compose.viewModel(), + avatarRepository: AvatarRepository? = null, + dialogDao: com.rosetta.messenger.database.DialogDao? = null ) { val context = LocalContext.current val activity = context as? FragmentActivity val biometricManager = remember { BiometricAuthManager(context) } val biometricPrefs = remember { BiometricPreferences(context) } val scope = rememberCoroutineScope() - + // Biometric state - var biometricAvailable by remember { mutableStateOf(BiometricAvailability.NotAvailable("Checking...")) } + var biometricAvailable by remember { + mutableStateOf(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(null) } - + // Check biometric availability LaunchedEffect(Unit) { biometricAvailable = biometricManager.isBiometricAvailable() isBiometricEnabled = biometricPrefs.isBiometricEnabled.first() } - + // Состояние меню аватара для установки фото профиля var showAvatarMenu by remember { mutableStateOf(false) } - + // URI выбранного изображения (до crop) var selectedImageUri by remember { mutableStateOf(null) } - + // Launcher для обрезки изображения (uCrop) - val cropLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.StartActivityForResult() - ) { result -> - Log.d(TAG, "✂️ Crop result: resultCode=${result.resultCode}") - - val croppedUri = ImageCropHelper.getCroppedImageUri(result) - val error = ImageCropHelper.getCropError(result) - - if (croppedUri != null) { - Log.d(TAG, "✅ Cropped image URI: $croppedUri") - scope.launch { - try { - // Читаем обрезанное изображение - val inputStream = context.contentResolver.openInputStream(croppedUri) - val imageBytes = inputStream?.readBytes() - inputStream?.close() - - Log.d(TAG, "📊 Cropped image bytes: ${imageBytes?.size ?: 0} bytes") - - if (imageBytes != null) { - Log.d(TAG, "🔄 Converting cropped image to PNG Base64...") - val base64Png = withContext(Dispatchers.IO) { - AvatarFileManager.imagePrepareForNetworkTransfer(context, imageBytes) + val cropLauncher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult() + ) { result -> + Log.d(TAG, "✂️ Crop result: resultCode=${result.resultCode}") + + val croppedUri = ImageCropHelper.getCroppedImageUri(result) + val error = ImageCropHelper.getCropError(result) + + if (croppedUri != null) { + Log.d(TAG, "✅ Cropped image URI: $croppedUri") + scope.launch { + try { + // Читаем обрезанное изображение + val inputStream = context.contentResolver.openInputStream(croppedUri) + val imageBytes = inputStream?.readBytes() + inputStream?.close() + + Log.d(TAG, "📊 Cropped image bytes: ${imageBytes?.size ?: 0} bytes") + + if (imageBytes != null) { + Log.d(TAG, "🔄 Converting cropped image to PNG Base64...") + val base64Png = + withContext(Dispatchers.IO) { + AvatarFileManager.imagePrepareForNetworkTransfer( + context, + imageBytes + ) + } + + Log.d(TAG, "✅ Converted to Base64: ${base64Png.length} chars") + + // Сохраняем аватар через репозиторий + avatarRepository?.changeMyAvatar(base64Png) + + Log.d(TAG, "🎉 Avatar update completed") + + android.widget.Toast.makeText( + context, + "Avatar updated successfully", + android.widget.Toast.LENGTH_SHORT + ) + .show() + } + } catch (e: Exception) { + Log.e(TAG, "❌ Failed to process cropped avatar", e) + android.widget.Toast.makeText( + context, + "Failed to update avatar: ${e.message}", + android.widget.Toast.LENGTH_LONG + ) + .show() } - - Log.d(TAG, "✅ Converted to Base64: ${base64Png.length} chars") - - // Сохраняем аватар через репозиторий - avatarRepository?.changeMyAvatar(base64Png) - - Log.d(TAG, "🎉 Avatar update completed") - - android.widget.Toast.makeText( - context, - "Avatar updated successfully", - android.widget.Toast.LENGTH_SHORT - ).show() } - } catch (e: Exception) { - Log.e(TAG, "❌ Failed to process cropped avatar", e) + } else if (error != null) { + Log.e(TAG, "❌ Crop error", error) android.widget.Toast.makeText( - context, - "Failed to update avatar: ${e.message}", - android.widget.Toast.LENGTH_LONG - ).show() + context, + "Failed to crop image: ${error.message}", + android.widget.Toast.LENGTH_LONG + ) + .show() + } else { + Log.w(TAG, "⚠️ Crop cancelled") } } - } else if (error != null) { - Log.e(TAG, "❌ Crop error", error) - android.widget.Toast.makeText( - context, - "Failed to crop image: ${error.message}", - android.widget.Toast.LENGTH_LONG - ).show() - } else { - Log.w(TAG, "⚠️ Crop cancelled") - } - } - + // Image picker launcher - после выбора открываем crop - val imagePickerLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.GetContent() - ) { uri: Uri? -> - Log.d(TAG, "🖼️ Image picker result: uri=$uri") - uri?.let { - // Запускаем uCrop для обрезки - val cropIntent = ImageCropHelper.createCropIntent(context, it, isDarkTheme) - cropLauncher.launch(cropIntent) - } ?: Log.w(TAG, "⚠️ URI is null, image picker cancelled") - } - + val imagePickerLauncher = + rememberLauncherForActivityResult(contract = ActivityResultContracts.GetContent()) { + uri: Uri? -> + Log.d(TAG, "🖼️ Image picker result: uri=$uri") + uri?.let { + // Запускаем uCrop для обрезки + val cropIntent = ImageCropHelper.createCropIntent(context, it, isDarkTheme) + cropLauncher.launch(cropIntent) + } + ?: Log.w(TAG, "⚠️ URI is null, image picker cancelled") + } + // Цвета в зависимости от темы val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF) val surfaceColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFF2F2F7) val textColor = if (isDarkTheme) Color.White else Color.Black val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) val avatarColors = getAvatarColor(accountPublicKey, isDarkTheme) - + // Проверяем наличие аватара - val avatars by avatarRepository?.getAvatars(accountPublicKey, allDecode = false)?.collectAsState() - ?: remember { mutableStateOf(emptyList()) } + val avatars by + avatarRepository?.getAvatars(accountPublicKey, allDecode = false)?.collectAsState() + ?: remember { mutableStateOf(emptyList()) } val hasAvatar = avatars.isNotEmpty() // State for editing - Update when account data changes var editedName by remember(accountName) { mutableStateOf(accountName) } var editedUsername by remember(accountUsername) { mutableStateOf(accountUsername) } var hasChanges by remember { mutableStateOf(false) } - + // Sync edited fields when account data changes from parent (after save) LaunchedEffect(accountName, accountUsername) { editedName = accountName editedUsername = accountUsername } - + // ViewModel state val profileState by viewModel.state.collectAsState() - + // Scroll state for collapsing header animation val density = LocalDensity.current val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() val expandedHeightPx = with(density) { (EXPANDED_HEADER_HEIGHT + statusBarHeight).toPx() } val collapsedHeightPx = with(density) { (COLLAPSED_HEADER_HEIGHT + statusBarHeight).toPx() } - + // Track scroll offset with animated state for smooth transitions + // Может быть отрицательным для overscroll (оттягивание вверх) var scrollOffset by remember { mutableFloatStateOf(0f) } val maxScrollOffset = expandedHeightPx - collapsedHeightPx - - // Calculate collapse progress (0 = expanded, 1 = collapsed) + val minScrollOffset = -expandedHeightPx * 0.3f // Максимальный overscroll (30% высоты) + + // Calculate collapse progress (0 = expanded, 1 = collapsed, negative = overscroll) val collapseProgress by remember { - derivedStateOf { - (scrollOffset / maxScrollOffset).coerceIn(0f, 1f) - } + derivedStateOf { (scrollOffset / maxScrollOffset).coerceIn(-1f, 1f) } } - - // Nested scroll connection for tracking scroll + + // Overscroll progress (0 = normal, 1 = full overscroll - квадратный аватар) + val overscrollProgress by remember { + derivedStateOf { (-scrollOffset / (-minScrollOffset)).coerceIn(0f, 1f) } + } + + // Nested scroll connection for tracking scroll with overscroll support val nestedScrollConnection = remember { object : NestedScrollConnection { override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { val delta = available.y val newOffset = scrollOffset - delta - val consumed = when { - delta < 0 && scrollOffset < maxScrollOffset -> { - val consumed = (newOffset.coerceIn(0f, maxScrollOffset) - scrollOffset) - scrollOffset = newOffset.coerceIn(0f, maxScrollOffset) - -consumed - } - delta > 0 && scrollOffset > 0 -> { - val consumed = scrollOffset - newOffset.coerceIn(0f, maxScrollOffset) - scrollOffset = newOffset.coerceIn(0f, maxScrollOffset) - consumed - } - else -> 0f - } + val consumed = + when { + // Scroll up (collapse) + delta < 0 && scrollOffset < maxScrollOffset -> { + val consumed = + (newOffset.coerceIn(minScrollOffset, maxScrollOffset) - + scrollOffset) + scrollOffset = newOffset.coerceIn(minScrollOffset, maxScrollOffset) + -consumed + } + // Scroll down (expand / overscroll) + delta > 0 && scrollOffset > minScrollOffset -> { + val consumed = + scrollOffset - + newOffset.coerceIn(minScrollOffset, maxScrollOffset) + scrollOffset = newOffset.coerceIn(minScrollOffset, maxScrollOffset) + consumed + } + else -> 0f + } return Offset(0f, consumed) } } } - + // Show success toast and update local profile LaunchedEffect(profileState.saveSuccess) { if (profileState.saveSuccess) { android.widget.Toast.makeText( - context, - "Profile updated successfully", - android.widget.Toast.LENGTH_SHORT - ).show() - + context, + "Profile updated successfully", + android.widget.Toast.LENGTH_SHORT + ) + .show() + // Following desktop version: update local data AFTER server confirms success viewModel.updateLocalProfile(accountPublicKey, editedName, editedUsername) - + hasChanges = false viewModel.resetSaveState() - + // Notify parent about profile update (updates UI in MainActivity) onSaveProfile(editedName, editedUsername) } } - + // Show error toast LaunchedEffect(profileState.error) { profileState.error?.let { error -> android.widget.Toast.makeText( - context, - "Error: $error", - android.widget.Toast.LENGTH_SHORT - ).show() + context, + "Error: $error", + android.widget.Toast.LENGTH_SHORT + ) + .show() viewModel.resetSaveState() } } @@ -364,23 +381,30 @@ fun ProfileScreen( LaunchedEffect(editedName, editedUsername) { hasChanges = editedName != accountName || editedUsername != accountUsername } - + // Handle back button press - navigate to chat list instead of closing app - BackHandler { - onBack() - } + BackHandler { onBack() } Box( - modifier = Modifier - .fillMaxSize() - .background(backgroundColor) - .nestedScroll(nestedScrollConnection) + modifier = + Modifier.fillMaxSize() + .background(backgroundColor) + .nestedScroll(nestedScrollConnection) ) { // Scrollable content LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(top = with(density) { (expandedHeightPx - scrollOffset).toDp() }) + modifier = + Modifier.fillMaxSize() + .padding( + top = + with(density) { + // Не увеличиваем padding при overscroll + // (scrollOffset < 0) + (expandedHeightPx - + scrollOffset.coerceAtLeast(0f)) + .toDp() + } + ) ) { item { Spacer(modifier = Modifier.height(16.dp)) @@ -392,34 +416,32 @@ fun ProfileScreen( // Name field TelegramTextField( - value = editedName, - label = "Your name", - isDarkTheme = isDarkTheme, - isEditable = true, - onValueChange = { editedName = it }, - showDivider = true, - placeholder = "Add your name" + value = editedName, + label = "Your name", + isDarkTheme = isDarkTheme, + isEditable = true, + onValueChange = { editedName = it }, + showDivider = true, + placeholder = "Add your name" ) // Username field TelegramTextField( - value = editedUsername, - label = "Username", - isDarkTheme = isDarkTheme, - isEditable = true, - onValueChange = { - editedUsername = it - }, - showDivider = true, - placeholder = "Add username" + value = editedUsername, + label = "Username", + isDarkTheme = isDarkTheme, + isEditable = true, + onValueChange = { editedUsername = it }, + showDivider = true, + placeholder = "Add username" ) // Public Key field TelegramCopyField( - value = accountPublicKey.take(16) + "..." + accountPublicKey.takeLast(6), - fullValue = accountPublicKey, - label = "Public Key", - isDarkTheme = isDarkTheme + value = accountPublicKey.take(16) + "..." + accountPublicKey.takeLast(6), + fullValue = accountPublicKey, + label = "Public Key", + isDarkTheme = isDarkTheme ) Spacer(modifier = Modifier.height(24.dp)) @@ -430,54 +452,55 @@ fun ProfileScreen( TelegramSectionTitle(title = "Settings", isDarkTheme = isDarkTheme) TelegramSettingsItem( - icon = TablerIcons.Palette, - title = "Theme", - onClick = onNavigateToTheme, - isDarkTheme = isDarkTheme, - showDivider = true + icon = TablerIcons.Palette, + title = "Theme", + onClick = onNavigateToTheme, + isDarkTheme = isDarkTheme, + showDivider = true ) TelegramSettingsItem( - icon = TablerIcons.Lock, - title = "Safety", - onClick = onNavigateToSafety, - isDarkTheme = isDarkTheme, - showDivider = true + icon = TablerIcons.Lock, + title = "Safety", + onClick = onNavigateToSafety, + isDarkTheme = isDarkTheme, + showDivider = true ) - + TelegramSettingsItem( - icon = TablerIcons.Bug, - title = "Crash Logs", - onClick = onNavigateToCrashLogs, - isDarkTheme = isDarkTheme, - showDivider = biometricAvailable is BiometricAvailability.Available + icon = TablerIcons.Bug, + title = "Crash Logs", + onClick = onNavigateToCrashLogs, + isDarkTheme = isDarkTheme, + showDivider = biometricAvailable is BiometricAvailability.Available ) - + // Biometric toggle (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() + 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 } - } else { - // Enable biometric - show password dialog first - passwordInput = "" - passwordError = null - showPasswordDialog = true - } - }, - isDarkTheme = isDarkTheme + }, + isDarkTheme = isDarkTheme ) } @@ -486,10 +509,7 @@ fun ProfileScreen( // ═════════════════════════════════════════════════════════════ // 🚪 LOGOUT - Telegram style (red text) // ═════════════════════════════════════════════════════════════ - TelegramLogoutItem( - onClick = onLogout, - isDarkTheme = isDarkTheme - ) + TelegramLogoutItem(onClick = onLogout, isDarkTheme = isDarkTheme) Spacer(modifier = Modifier.height(32.dp)) } @@ -499,139 +519,135 @@ fun ProfileScreen( // 🎨 COLLAPSING HEADER - Telegram style // ═════════════════════════════════════════════════════════════ CollapsingProfileHeader( - name = editedName.ifBlank { accountPublicKey.take(10) }, - username = editedUsername, - publicKey = accountPublicKey, - avatarColors = avatarColors, - collapseProgress = collapseProgress, - onBack = onBack, - hasChanges = hasChanges, - onSave = { - // Following desktop version logic: - // 1. Send packet to server - // 2. Wait for response in ProfileViewModel - // 3. Update local data only on success (in LaunchedEffect above) - viewModel.saveProfile( - publicKey = accountPublicKey, - privateKeyHash = accountPrivateKeyHash, - name = editedName, - username = editedUsername - ) - // Note: Local update happens in LaunchedEffect when saveSuccess is true - }, - isDarkTheme = isDarkTheme, - showAvatarMenu = showAvatarMenu, - onAvatarMenuChange = { showAvatarMenu = it }, - onSetPhotoClick = { - imagePickerLauncher.launch("image/*") - }, - onDeletePhotoClick = { - // Удаляем аватар - scope.launch { - avatarRepository?.deleteMyAvatar() - android.widget.Toast.makeText( - context, - "Avatar deleted", - android.widget.Toast.LENGTH_SHORT - ).show() - } - }, - hasAvatar = hasAvatar, - avatarRepository = avatarRepository + name = editedName.ifBlank { accountPublicKey.take(10) }, + username = editedUsername, + publicKey = accountPublicKey, + avatarColors = avatarColors, + collapseProgress = collapseProgress, + overscrollProgress = overscrollProgress, + onBack = onBack, + hasChanges = hasChanges, + onSave = { + // Following desktop version logic: + // 1. Send packet to server + // 2. Wait for response in ProfileViewModel + // 3. Update local data only on success (in LaunchedEffect above) + viewModel.saveProfile( + publicKey = accountPublicKey, + privateKeyHash = accountPrivateKeyHash, + name = editedName, + username = editedUsername + ) + // Note: Local update happens in LaunchedEffect when saveSuccess is true + }, + isDarkTheme = isDarkTheme, + showAvatarMenu = showAvatarMenu, + onAvatarMenuChange = { showAvatarMenu = it }, + onSetPhotoClick = { imagePickerLauncher.launch("image/*") }, + onDeletePhotoClick = { + // Удаляем аватар + scope.launch { + avatarRepository?.deleteMyAvatar() + android.widget.Toast.makeText( + context, + "Avatar deleted", + android.widget.Toast.LENGTH_SHORT + ) + .show() + } + }, + hasAvatar = hasAvatar, + avatarRepository = avatarRepository ) } - + // 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 = "" + 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 - - android.widget.Toast.makeText( - context, - "Biometric authentication enabled successfully", - android.widget.Toast.LENGTH_SHORT - ).show() + }, + 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 } - }, - onError = { error -> - passwordError = error - }, - onCancel = { + + // 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("Enable") + ) { Text("Cancel") } } - }, - dismissButton = { - TextButton( - onClick = { - showPasswordDialog = false - passwordInput = "" - passwordError = null - } - ) { - Text("Cancel") - } - } ) } } @@ -641,289 +657,298 @@ fun ProfileScreen( // ═════════════════════════════════════════════════════════════ @Composable private fun CollapsingProfileHeader( - name: String, - username: String, - publicKey: String, - avatarColors: AvatarColors, - collapseProgress: Float, - onBack: () -> Unit, - hasChanges: Boolean, - onSave: () -> Unit, - isDarkTheme: Boolean, - showAvatarMenu: Boolean, - onAvatarMenuChange: (Boolean) -> Unit, - onSetPhotoClick: () -> Unit, - onDeletePhotoClick: () -> Unit, - hasAvatar: Boolean, - avatarRepository: AvatarRepository? + name: String, + username: String, + publicKey: String, + avatarColors: AvatarColors, + collapseProgress: Float, + overscrollProgress: Float, // 0 = normal, 1 = full overscroll (квадратный аватар) + onBack: () -> Unit, + hasChanges: Boolean, + onSave: () -> Unit, + isDarkTheme: Boolean, + showAvatarMenu: Boolean, + onAvatarMenuChange: (Boolean) -> Unit, + onSetPhotoClick: () -> Unit, + onDeletePhotoClick: () -> Unit, + hasAvatar: Boolean, + avatarRepository: AvatarRepository? ) { val density = LocalDensity.current val configuration = LocalConfiguration.current val screenWidthDp = configuration.screenWidthDp.dp - + // Get actual status bar height val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() - + // Header heights // По умолчанию header = ширина экрана минус отступ для Account, при скролле уменьшается - val expandedHeight = screenWidthDp - 60.dp // Высота header (меньше чтобы не перекрывать Account) + val expandedHeight = + screenWidthDp - 60.dp // Высота header (меньше чтобы не перекрывать Account) val collapsedHeight = COLLAPSED_HEADER_HEIGHT + statusBarHeight - - // Animated header height - val headerHeight = androidx.compose.ui.unit.lerp(expandedHeight, collapsedHeight, collapseProgress) - + + // Animated header height - НЕ увеличивается при overscroll + val headerHeight = + androidx.compose.ui.unit.lerp( + expandedHeight, + collapsedHeight, + collapseProgress.coerceAtLeast(0f) + ) + // ═══════════════════════════════════════════════════════════ - // 👤 AVATAR - По умолчанию прямоугольный во весь header, при скролле становится круглым + // 👤 AVATAR - По умолчанию круглый, при overscroll становится квадратным + // Аватар всегда ограничен размером header // ═══════════════════════════════════════════════════════════ - // Размеры аватара - всегда помещается в header - // При collapseProgress=0: ширина=screenWidth, высота=expandedHeight - // При collapseProgress=1: размер=0 - - // Промежуточный размер для круга (когда становится круглым) + + // Размер круглого аватара по умолчанию val circleSize = AVATAR_SIZE_EXPANDED - - // Плавный переход: сначала уменьшается до круга, потом до 0 + + // При overscroll: от круга до полного размера header + // При collapse: от круга до 0 val avatarWidth: Dp val avatarHeight: Dp - - if (collapseProgress < 0.4f) { - // Фаза 1: от полного размера до круга - val phase1Progress = collapseProgress / 0.4f - // Добавляем 4.dp чтобы гарантированно закрыть края - avatarWidth = androidx.compose.ui.unit.lerp(screenWidthDp , circleSize, phase1Progress) - avatarHeight = androidx.compose.ui.unit.lerp(expandedHeight, circleSize, phase1Progress) + val avatarX: Dp + val avatarY: Dp + val cornerRadius: Dp + + val collapseOnly = collapseProgress.coerceAtLeast(0f) + + if (overscrollProgress > 0f) { + // OVERSCROLL: аватар становится квадратным и занимает весь header + // Header остаётся фиксированным = expandedHeight + avatarWidth = androidx.compose.ui.unit.lerp(circleSize, screenWidthDp, overscrollProgress) + avatarHeight = androidx.compose.ui.unit.lerp(circleSize, expandedHeight, overscrollProgress) + avatarX = + androidx.compose.ui.unit.lerp( + (screenWidthDp - circleSize) / 2, + 0.dp, + overscrollProgress + ) + val avatarCenterY = (expandedHeight - circleSize) / 2 + avatarY = androidx.compose.ui.unit.lerp(avatarCenterY, 0.dp, overscrollProgress) + // Закругление: от круга до квадрата + cornerRadius = androidx.compose.ui.unit.lerp(circleSize / 2, 0.dp, overscrollProgress) } else { - // Фаза 2: от круга до 0 - val phase2Progress = (collapseProgress - 0.4f) / 0.6f - avatarWidth = androidx.compose.ui.unit.lerp(circleSize, 0.dp, phase2Progress) - avatarHeight = androidx.compose.ui.unit.lerp(circleSize, 0.dp, phase2Progress) + // NORMAL / COLLAPSE: круглый аватар уменьшается при скролле + avatarWidth = androidx.compose.ui.unit.lerp(circleSize, 0.dp, collapseOnly) + avatarHeight = androidx.compose.ui.unit.lerp(circleSize, 0.dp, collapseOnly) + avatarX = (screenWidthDp - avatarWidth) / 2 + + // Позиция Y: по центру header при развернутом, уходит вверх при сворачивании + val avatarCenterY = (expandedHeight - circleSize) / 2 + avatarY = + if (collapseOnly < 0.5f) { + androidx.compose.ui.unit.lerp( + avatarCenterY, + statusBarHeight + 14.dp, + collapseOnly * 2 + ) + } else { + val phase2Progress = (collapseOnly - 0.5f) / 0.5f + androidx.compose.ui.unit.lerp( + statusBarHeight + 14.dp, + statusBarHeight - 60.dp, + phase2Progress + ) + } + // Всегда круглый + cornerRadius = avatarWidth / 2 } - + // Для cornerRadius используем меньшую сторону val avatarSize = minOf(avatarWidth, avatarHeight) - - // Позиция X: центрируем аватар (с учётом добавленных 4dp) - val avatarX = (screenWidthDp - avatarWidth) / 2 - 2.dp - - // Позиция Y: от 0 до центра header, потом уходит вверх - val avatarY = if (collapseProgress < 0.4f) { - // Фаза 1: остаётся наверху - 0.dp - } else if (collapseProgress < 0.7f) { - // Фаза 2: опускается в позицию круга - val phase2Progress = (collapseProgress - 0.4f) / 0.3f - androidx.compose.ui.unit.lerp(0.dp, statusBarHeight + 32.dp, phase2Progress) - } else { - // Фаза 3: уходит вверх за экран - val phase3Progress = (collapseProgress - 0.7f) / 0.3f - androidx.compose.ui.unit.lerp(statusBarHeight + 32.dp, statusBarHeight - 60.dp, phase3Progress) - } - - // Закругление: от 0 (квадрат) до половины размера (круг) - val cornerRadius = if (collapseProgress < 0.3f) { - androidx.compose.ui.unit.lerp(0.dp, avatarSize / 2, collapseProgress / 0.3f) - } else { - avatarSize / 2 // Полный круг - } - - val avatarFontSize = androidx.compose.ui.unit.lerp(40.sp, 0.sp, collapseProgress) - + + val avatarFontSize = androidx.compose.ui.unit.lerp(40.sp, 0.sp, collapseOnly) + // ═══════════════════════════════════════════════════════════ - // 📝 TEXT - always centered + // 📝 TEXT - always centered, under avatar // ═══════════════════════════════════════════════════════════ val textX = screenWidthDp / 2 // Always center - - val textExpandedY = statusBarHeight + 32.dp + AVATAR_SIZE_EXPANDED + 48.dp + + // Позиция Y аватара для расчета текста (используем expandedHeight для стабильности) + val avatarCenterYForText = (expandedHeight - circleSize) / 2 + + // Позиция текста: под аватаром по центру + val avatarBottomY = avatarCenterYForText + circleSize // Низ аватара + val textExpandedY = avatarBottomY + 16.dp // 16dp отступ от аватара val textCollapsedY = statusBarHeight + COLLAPSED_HEADER_HEIGHT / 2 - val textY = androidx.compose.ui.unit.lerp(textExpandedY, textCollapsedY, collapseProgress) - + val textY = androidx.compose.ui.unit.lerp(textExpandedY, textCollapsedY, collapseOnly) + // Font sizes - val nameFontSize = androidx.compose.ui.unit.lerp(24.sp, 18.sp, collapseProgress) - val onlineFontSize = androidx.compose.ui.unit.lerp(14.sp, 13.sp, collapseProgress) - + val nameFontSize = androidx.compose.ui.unit.lerp(24.sp, 18.sp, collapseOnly) + val onlineFontSize = androidx.compose.ui.unit.lerp(14.sp, 13.sp, collapseOnly) + Box( - modifier = Modifier - .fillMaxWidth() - .height(headerHeight) + modifier = + Modifier.fillMaxWidth() + .height(headerHeight) + .clip( + RoundedCornerShape(0.dp) + ) // Обрезаем содержимое по границам header ) { // ═══════════════════════════════════════════════════════════ - // 🎨 BLURRED AVATAR BACKGROUND - только когда аватар уже круглый - // При квадратном аватаре фон не нужен (аватар сам занимает весь header) + // 🎨 BLURRED AVATAR BACKGROUND - скрываем при overscroll (аватар сам закрывает фон) // ═══════════════════════════════════════════════════════════ - if (collapseProgress > 0.3f) { + if (overscrollProgress < 0.5f) { BlurredAvatarBackground( - publicKey = publicKey, - avatarRepository = avatarRepository, - fallbackColor = avatarColors.backgroundColor, - blurRadius = 25f, - alpha = 0.3f * ((collapseProgress - 0.3f) / 0.7f).coerceIn(0f, 1f) // Плавное появление + publicKey = publicKey, + avatarRepository = avatarRepository, + fallbackColor = avatarColors.backgroundColor, + blurRadius = 25f, + alpha = 0.3f ) } - + // ═══════════════════════════════════════════════════════════ - // � AVATAR - По умолчанию квадратный, при скролле становится круглым + // 👤 AVATAR - По умолчанию круглый по центру // РИСУЕМ ПЕРВЫМ чтобы кнопки были поверх // ═══════════════════════════════════════════════════════════ if (avatarSize > 1.dp) { Box( - modifier = Modifier - .offset( - x = avatarX, - y = avatarY - ) - .width(avatarWidth) - .height(avatarHeight) - .clip(RoundedCornerShape(cornerRadius)), - contentAlignment = Alignment.Center + modifier = + Modifier.offset(x = avatarX, y = avatarY) + .width(avatarWidth) + .height(avatarHeight) + .clip(RoundedCornerShape(cornerRadius)), + contentAlignment = Alignment.Center ) { // Используем AvatarImage если репозиторий доступен if (avatarRepository != null) { // Всегда используем FullSizeAvatar чтобы избежать мерцания при переключении FullSizeAvatar( - publicKey = publicKey, - avatarRepository = avatarRepository, - isDarkTheme = isDarkTheme + publicKey = publicKey, + avatarRepository = avatarRepository, + isDarkTheme = isDarkTheme ) } else { // Fallback: цветной placeholder с инициалами Box( - modifier = Modifier - .fillMaxSize() - .background(avatarColors.backgroundColor), - contentAlignment = Alignment.Center + modifier = + Modifier.fillMaxSize().background(avatarColors.backgroundColor), + contentAlignment = Alignment.Center ) { if (avatarFontSize > 1.sp) { Text( - text = getInitials(name), - fontSize = avatarFontSize, - fontWeight = FontWeight.Bold, - color = avatarColors.textColor + text = getInitials(name), + fontSize = avatarFontSize, + fontWeight = FontWeight.Bold, + color = avatarColors.textColor ) } } } } } - + // ═══════════════════════════════════════════════════════════ // 🔙 BACK BUTTON (поверх аватара) // ═══════════════════════════════════════════════════════════ Box( - modifier = Modifier - .padding(top = statusBarHeight) - .padding(start = 4.dp, top = 4.dp) - .size(48.dp), - contentAlignment = Alignment.Center + modifier = + Modifier.padding(top = statusBarHeight) + .padding(start = 4.dp, top = 4.dp) + .size(48.dp), + contentAlignment = Alignment.Center ) { - IconButton( - onClick = onBack, - modifier = Modifier.size(48.dp) - ) { + IconButton(onClick = onBack, modifier = Modifier.size(48.dp)) { Icon( - imageVector = TablerIcons.ArrowLeft, - contentDescription = "Back", - tint = Color.White, - modifier = Modifier.size(24.dp) + imageVector = TablerIcons.ArrowLeft, + contentDescription = "Back", + tint = Color.White, + modifier = Modifier.size(24.dp) ) } } - + // ═══════════════════════════════════════════════════════════ // ⋮ MENU BUTTON / 💾 SAVE BUTTON (top right corner) // Показываем Save если есть изменения, иначе три точки меню // ═══════════════════════════════════════════════════════════ Box( - modifier = Modifier - .align(Alignment.TopEnd) - .padding(top = statusBarHeight) - .padding(end = 4.dp, top = 4.dp), - contentAlignment = Alignment.Center + modifier = + Modifier.align(Alignment.TopEnd) + .padding(top = statusBarHeight) + .padding(end = 4.dp, top = 4.dp), + contentAlignment = Alignment.Center ) { // Save button (when has changes) - AnimatedVisibility( - visible = hasChanges, - enter = fadeIn(), - exit = fadeOut() - ) { + AnimatedVisibility(visible = hasChanges, enter = fadeIn(), exit = fadeOut()) { TextButton(onClick = onSave) { Text( - text = "Save", - color = if (isDarkTheme) Color.White else Color.Black, - fontWeight = FontWeight.SemiBold + text = "Save", + color = if (isDarkTheme) Color.White else Color.Black, + fontWeight = FontWeight.SemiBold ) } } - + // Menu button (when no changes) - AnimatedVisibility( - visible = !hasChanges, - enter = fadeIn(), - exit = fadeOut() - ) { + AnimatedVisibility(visible = !hasChanges, enter = fadeIn(), exit = fadeOut()) { IconButton( - onClick = { onAvatarMenuChange(true) }, - modifier = Modifier.size(48.dp) + onClick = { onAvatarMenuChange(true) }, + modifier = Modifier.size(48.dp) ) { Icon( - imageVector = TablerIcons.DotsVertical, - contentDescription = "Profile menu", - tint = Color.White, // Всегда белые - на фоне аватара - modifier = Modifier.size(24.dp) + imageVector = TablerIcons.DotsVertical, + contentDescription = "Profile menu", + tint = Color.White, // Всегда белые - на фоне аватара + modifier = Modifier.size(24.dp) ) } } - + // Меню для установки фото профиля com.rosetta.messenger.ui.chats.components.ProfilePhotoMenu( - expanded = showAvatarMenu, - onDismiss = { onAvatarMenuChange(false) }, - isDarkTheme = isDarkTheme, - onSetPhotoClick = { - onAvatarMenuChange(false) - onSetPhotoClick() - }, - onDeletePhotoClick = { - onAvatarMenuChange(false) - onDeletePhotoClick() - }, - hasAvatar = hasAvatar + expanded = showAvatarMenu, + onDismiss = { onAvatarMenuChange(false) }, + isDarkTheme = isDarkTheme, + onSetPhotoClick = { + onAvatarMenuChange(false) + onSetPhotoClick() + }, + onDeletePhotoClick = { + onAvatarMenuChange(false) + onDeletePhotoClick() + }, + hasAvatar = hasAvatar ) } - + // ═══════════════════════════════════════════════════════════ // 📝 TEXT BLOCK - Name + Online, always centered // ═══════════════════════════════════════════════════════════ Column( - modifier = Modifier - .align(Alignment.TopCenter) - .offset(y = textY) - .graphicsLayer { - val centerOffsetY = with(density) { - androidx.compose.ui.unit.lerp(24.dp, 18.dp, collapseProgress).toPx() - } - translationY = -centerOffsetY - }, - horizontalAlignment = Alignment.CenterHorizontally + modifier = + Modifier.align(Alignment.TopCenter).offset(y = textY).graphicsLayer { + val centerOffsetY = + with(density) { + androidx.compose + .ui + .unit + .lerp(24.dp, 18.dp, collapseProgress) + .toPx() + } + translationY = -centerOffsetY + }, + horizontalAlignment = Alignment.CenterHorizontally ) { Text( - text = name, - fontSize = nameFontSize, - fontWeight = FontWeight.SemiBold, - color = if (isColorLight(avatarColors.backgroundColor)) Color.Black else Color.White, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.widthIn(max = 220.dp), - textAlign = TextAlign.Center + text = name, + fontSize = nameFontSize, + fontWeight = FontWeight.SemiBold, + color = + if (isColorLight(avatarColors.backgroundColor)) Color.Black + else Color.White, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.widthIn(max = 220.dp), + textAlign = TextAlign.Center ) - + Spacer(modifier = Modifier.height(2.dp)) - + // Online text - always centered - Text( - text = "online", - fontSize = onlineFontSize, - color = Color(0xFF4CAF50) - ) + Text(text = "online", fontSize = onlineFontSize, color = Color(0xFF4CAF50)) } } } @@ -933,45 +958,47 @@ private fun CollapsingProfileHeader( // ═════════════════════════════════════════════════════════════ @Composable private fun FullSizeAvatar( - publicKey: String, - avatarRepository: AvatarRepository?, - isDarkTheme: Boolean = false + publicKey: String, + avatarRepository: AvatarRepository?, + isDarkTheme: Boolean = false ) { - val avatars by avatarRepository?.getAvatars(publicKey, allDecode = false)?.collectAsState() - ?: remember { mutableStateOf(emptyList()) } - + val avatars by + avatarRepository?.getAvatars(publicKey, allDecode = false)?.collectAsState() + ?: remember { mutableStateOf(emptyList()) } + var bitmap by remember(avatars) { mutableStateOf(null) } - + LaunchedEffect(avatars) { - bitmap = if (avatars.isNotEmpty()) { - kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { - com.rosetta.messenger.utils.AvatarFileManager.base64ToBitmap(avatars.first().base64Data) - } - } else { - null - } + bitmap = + if (avatars.isNotEmpty()) { + kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { + com.rosetta.messenger.utils.AvatarFileManager.base64ToBitmap( + avatars.first().base64Data + ) + } + } else { + null + } } - + if (bitmap != null) { Image( - bitmap = bitmap!!.asImageBitmap(), - contentDescription = "Avatar", - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop + bitmap = bitmap!!.asImageBitmap(), + contentDescription = "Avatar", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop ) } else { val avatarColors = getAvatarColor(publicKey, isDarkTheme) Box( - modifier = Modifier - .fillMaxSize() - .background(avatarColors.backgroundColor), - contentAlignment = Alignment.Center + modifier = Modifier.fillMaxSize().background(avatarColors.backgroundColor), + contentAlignment = Alignment.Center ) { Text( - text = publicKey.take(2).uppercase(), - color = avatarColors.textColor, - fontSize = 80.sp, - fontWeight = FontWeight.Bold + text = publicKey.take(2).uppercase(), + color = avatarColors.textColor, + fontSize = 80.sp, + fontWeight = FontWeight.Bold ) } } @@ -982,79 +1009,70 @@ private fun FullSizeAvatar( // ═════════════════════════════════════════════════════════════ @Composable fun ProfileCard( - name: String, - username: String, - publicKey: String, - isDarkTheme: Boolean, - onBack: (() -> Unit)?, - hasChanges: Boolean, - onSave: () -> Unit + name: String, + username: String, + publicKey: String, + isDarkTheme: Boolean, + onBack: (() -> Unit)?, + hasChanges: Boolean, + onSave: () -> Unit ) { val avatarColors = getAvatarColor(publicKey, isDarkTheme) - Box( - modifier = Modifier - .fillMaxWidth() - .background(avatarColors.backgroundColor) - ) { + Box(modifier = Modifier.fillMaxWidth().background(avatarColors.backgroundColor)) { // Back button (if callback provided) if (onBack != null) { IconButton( - onClick = onBack, - modifier = Modifier - .align(Alignment.TopStart) - .statusBarsPadding() - .padding(4.dp) + onClick = onBack, + modifier = Modifier.align(Alignment.TopStart).statusBarsPadding().padding(4.dp) ) { Icon( - imageVector = TablerIcons.ArrowLeft, - contentDescription = "Back", - tint = Color.White + imageVector = TablerIcons.ArrowLeft, + contentDescription = "Back", + tint = Color.White ) } } - + // Save button (if changes) AnimatedVisibility( - visible = hasChanges, - enter = fadeIn() + expandHorizontally(), - exit = fadeOut() + shrinkHorizontally(), - modifier = Modifier - .align(Alignment.TopEnd) - .statusBarsPadding() - .padding(end = 52.dp, top = 4.dp) // Сдвинуто левее + visible = hasChanges, + enter = fadeIn() + expandHorizontally(), + exit = fadeOut() + shrinkHorizontally(), + modifier = + Modifier.align(Alignment.TopEnd) + .statusBarsPadding() + .padding(end = 52.dp, top = 4.dp) // Сдвинуто левее ) { TextButton(onClick = onSave) { Text( - text = "Save", - color = if (isDarkTheme) Color.White else Color.Black, - fontWeight = FontWeight.SemiBold + text = "Save", + color = if (isDarkTheme) Color.White else Color.Black, + fontWeight = FontWeight.SemiBold ) } } - + Column( - modifier = Modifier - .fillMaxWidth() - .padding(top = 80.dp, bottom = 48.dp), - horizontalAlignment = Alignment.CenterHorizontally + modifier = Modifier.fillMaxWidth().padding(top = 80.dp, bottom = 48.dp), + horizontalAlignment = Alignment.CenterHorizontally ) { // 👤 Large Avatar - Telegram style Box( - modifier = Modifier - .size(140.dp) - .clip(CircleShape) - .background(Color.White.copy(alpha = 0.2f)) - .padding(3.dp) - .clip(CircleShape) - .background(avatarColors.backgroundColor), - contentAlignment = Alignment.Center + modifier = + Modifier.size(140.dp) + .clip(CircleShape) + .background(Color.White.copy(alpha = 0.2f)) + .padding(3.dp) + .clip(CircleShape) + .background(avatarColors.backgroundColor), + contentAlignment = Alignment.Center ) { Text( - text = getInitials(name), - fontSize = 48.sp, - fontWeight = FontWeight.Bold, - color = avatarColors.textColor + text = getInitials(name), + fontSize = 48.sp, + fontWeight = FontWeight.Bold, + color = avatarColors.textColor ) } @@ -1062,36 +1080,32 @@ fun ProfileCard( // Name Text( - text = name, - fontSize = 24.sp, - fontWeight = FontWeight.SemiBold, - color = Color.White, - textAlign = TextAlign.Center + text = name, + fontSize = 24.sp, + fontWeight = FontWeight.SemiBold, + color = Color.White, + textAlign = TextAlign.Center ) Spacer(modifier = Modifier.height(4.dp)) // Username and short public key Row( - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically ) { if (username.isNotBlank()) { Text( - text = "@$username", - fontSize = 14.sp, - color = Color.White.copy(alpha = 0.8f) - ) - Text( - text = " • ", - fontSize = 14.sp, - color = Color.White.copy(alpha = 0.8f) + text = "@$username", + fontSize = 14.sp, + color = Color.White.copy(alpha = 0.8f) ) + Text(text = " • ", fontSize = 14.sp, color = Color.White.copy(alpha = 0.8f)) } Text( - text = "${publicKey.take(3)}...${publicKey.takeLast(3)}", - fontSize = 14.sp, - color = Color.White.copy(alpha = 0.8f) + text = "${publicKey.take(3)}...${publicKey.takeLast(3)}", + fontSize = 14.sp, + color = Color.White.copy(alpha = 0.8f) ) } } @@ -1107,90 +1121,76 @@ fun TelegramSectionTitle(title: String, isDarkTheme: Boolean) { val textColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF8E8E93) Text( - text = title, - fontSize = 15.sp, - fontWeight = FontWeight.Medium, - color = textColor, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp) + text = title, + fontSize = 15.sp, + fontWeight = FontWeight.Medium, + color = textColor, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp) ) } @Composable private fun TelegramTextField( - value: String, - label: String, - isDarkTheme: Boolean, - isEditable: Boolean = false, - onValueChange: ((String) -> Unit)? = null, - showDivider: Boolean = false, - placeholder: String = "" + value: String, + label: String, + isDarkTheme: Boolean, + isEditable: Boolean = false, + onValueChange: ((String) -> Unit)? = null, + showDivider: Boolean = false, + placeholder: String = "" ) { val textColor = if (isDarkTheme) Color.White else Color.Black val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) val dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE0E0E0) Column { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp) - ) { + Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp)) { if (isEditable && onValueChange != null) { BasicTextField( - value = value, - onValueChange = onValueChange, - modifier = Modifier.fillMaxWidth(), - textStyle = TextStyle( - color = textColor, - fontSize = 16.sp - ), - singleLine = true, - cursorBrush = androidx.compose.ui.graphics.SolidColor(textColor), - decorationBox = { innerTextField -> - if (value.isEmpty() && placeholder.isNotEmpty()) { - Text( - text = placeholder, - fontSize = 16.sp, - color = secondaryTextColor.copy(alpha = 0.5f) - ) + value = value, + onValueChange = onValueChange, + modifier = Modifier.fillMaxWidth(), + textStyle = TextStyle(color = textColor, fontSize = 16.sp), + singleLine = true, + cursorBrush = androidx.compose.ui.graphics.SolidColor(textColor), + decorationBox = { innerTextField -> + if (value.isEmpty() && placeholder.isNotEmpty()) { + Text( + text = placeholder, + fontSize = 16.sp, + color = secondaryTextColor.copy(alpha = 0.5f) + ) + } + innerTextField() } - innerTextField() - } ) } else { Text( - text = value.ifBlank { placeholder }, - fontSize = 16.sp, - color = if (value.isBlank()) secondaryTextColor.copy(alpha = 0.5f) else textColor + text = value.ifBlank { placeholder }, + fontSize = 16.sp, + color = + if (value.isBlank()) secondaryTextColor.copy(alpha = 0.5f) + else textColor ) } - + Spacer(modifier = Modifier.height(4.dp)) - - Text( - text = label, - fontSize = 13.sp, - color = secondaryTextColor - ) + + Text(text = label, fontSize = 13.sp, color = secondaryTextColor) } if (showDivider) { Divider( - color = dividerColor, - thickness = 0.5.dp, - modifier = Modifier.padding(start = 16.dp) + color = dividerColor, + thickness = 0.5.dp, + modifier = Modifier.padding(start = 16.dp) ) } } } @Composable -fun TelegramCopyField( - value: String, - fullValue: String, - label: String, - isDarkTheme: Boolean -) { +fun TelegramCopyField(value: String, fullValue: String, label: String, isDarkTheme: Boolean) { val textColor = if (isDarkTheme) Color.White else Color.Black val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) val context = LocalContext.current @@ -1198,49 +1198,40 @@ fun TelegramCopyField( var showCopied by remember { mutableStateOf(false) } Column( - modifier = Modifier - .fillMaxWidth() - .clickable { - val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - val clip = ClipData.newPlainText(label, fullValue) - clipboard.setPrimaryClip(clip) - - scope.launch { - showCopied = true - delay(1500) - showCopied = false - } - } - .padding(horizontal = 16.dp, vertical = 12.dp) + modifier = + Modifier.fillMaxWidth() + .clickable { + val clipboard = + context.getSystemService(Context.CLIPBOARD_SERVICE) as + ClipboardManager + val clip = ClipData.newPlainText(label, fullValue) + clipboard.setPrimaryClip(clip) + + scope.launch { + showCopied = true + delay(1500) + showCopied = false + } + } + .padding(horizontal = 16.dp, vertical = 12.dp) ) { - AnimatedContent( - targetState = showCopied, - label = "copy_animation" - ) { copied -> - Text( - text = if (copied) "Copied!" else value, - fontSize = 16.sp, - color = textColor - ) + AnimatedContent(targetState = showCopied, label = "copy_animation") { copied -> + Text(text = if (copied) "Copied!" else value, fontSize = 16.sp, color = textColor) } - + Spacer(modifier = Modifier.height(4.dp)) - - Text( - text = label, - fontSize = 13.sp, - color = secondaryTextColor - ) + + Text(text = label, fontSize = 13.sp, color = secondaryTextColor) } } @Composable private fun TelegramSettingsItem( - icon: ImageVector, - title: String, - onClick: () -> Unit, - isDarkTheme: Boolean, - showDivider: Boolean = false + icon: ImageVector, + title: String, + onClick: () -> Unit, + isDarkTheme: Boolean, + showDivider: Boolean = false ) { val textColor = if (isDarkTheme) Color.White else Color.Black val iconColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) @@ -1248,147 +1239,135 @@ private fun TelegramSettingsItem( Column { Row( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onClick) - .padding(horizontal = 16.dp, vertical = 14.dp), - verticalAlignment = Alignment.CenterVertically + modifier = + Modifier.fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically ) { Icon( - imageVector = icon, - contentDescription = null, - tint = iconColor, - modifier = Modifier.size(24.dp) + imageVector = icon, + contentDescription = null, + tint = iconColor, + modifier = Modifier.size(24.dp) ) Spacer(modifier = Modifier.width(20.dp)) - Text( - text = title, - fontSize = 16.sp, - color = textColor, - modifier = Modifier.weight(1f) - ) + Text(text = title, fontSize = 16.sp, color = textColor, modifier = Modifier.weight(1f)) } if (showDivider) { Divider( - color = dividerColor, - thickness = 0.5.dp, - modifier = Modifier.padding(start = 60.dp) + color = dividerColor, + thickness = 0.5.dp, + modifier = Modifier.padding(start = 60.dp) ) } } } @Composable -private fun TelegramBiometricItem( - isEnabled: Boolean, - onToggle: () -> Unit, - isDarkTheme: Boolean -) { +private fun TelegramBiometricItem(isEnabled: Boolean, onToggle: () -> Unit, isDarkTheme: Boolean) { val textColor = if (isDarkTheme) Color.White else Color.Black val iconColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) val primaryBlue = Color(0xFF007AFF) Row( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onToggle) - .padding(horizontal = 16.dp, vertical = 14.dp), - verticalAlignment = Alignment.CenterVertically + modifier = + Modifier.fillMaxWidth() + .clickable(onClick = onToggle) + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically ) { Icon( - imageVector = TablerIcons.Fingerprint, - contentDescription = null, - tint = iconColor, - modifier = Modifier.size(24.dp) + imageVector = TablerIcons.Fingerprint, + contentDescription = null, + tint = iconColor, + modifier = Modifier.size(24.dp) ) Spacer(modifier = Modifier.width(20.dp)) Text( - text = "Biometric Authentication", - fontSize = 16.sp, - color = textColor, - modifier = Modifier.weight(1f) + text = "Biometric Authentication", + fontSize = 16.sp, + color = textColor, + modifier = Modifier.weight(1f) ) - + // iOS-style animated switch - val animatedThumbOffset by animateFloatAsState( - targetValue = if (isEnabled) 1f else 0f, - animationSpec = spring( - dampingRatio = Spring.DampingRatioMediumBouncy, - stiffness = Spring.StiffnessLow - ), - label = "switchThumb" - ) - - val trackColor by animateColorAsState( - targetValue = if (isEnabled) primaryBlue else - if (isDarkTheme) Color(0xFF39393D) else Color(0xFFE9E9EA), - animationSpec = tween(300), - label = "trackColor" - ) - - Box( - modifier = Modifier - .width(51.dp) - .height(31.dp) - .clip(RoundedCornerShape(15.5.dp)) - .background(trackColor) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - onClick = { onToggle() } + val animatedThumbOffset by + animateFloatAsState( + targetValue = if (isEnabled) 1f else 0f, + animationSpec = + spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessLow + ), + label = "switchThumb" ) - .padding(2.dp) + + val trackColor by + animateColorAsState( + targetValue = + if (isEnabled) primaryBlue + else if (isDarkTheme) Color(0xFF39393D) else Color(0xFFE9E9EA), + animationSpec = tween(300), + label = "trackColor" + ) + + Box( + modifier = + Modifier.width(51.dp) + .height(31.dp) + .clip(RoundedCornerShape(15.5.dp)) + .background(trackColor) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = { onToggle() } + ) + .padding(2.dp) ) { Box( - modifier = Modifier - .size(27.dp) - .align(Alignment.CenterStart) - .offset(x = (20.dp * animatedThumbOffset)) - .shadow( - elevation = if (isEnabled) 3.dp else 2.dp, - shape = CircleShape, - spotColor = Color.Black.copy(alpha = 0.15f) - ) - .clip(CircleShape) - .background(Color.White) + modifier = + Modifier.size(27.dp) + .align(Alignment.CenterStart) + .offset(x = (20.dp * animatedThumbOffset)) + .shadow( + elevation = if (isEnabled) 3.dp else 2.dp, + shape = CircleShape, + spotColor = Color.Black.copy(alpha = 0.15f) + ) + .clip(CircleShape) + .background(Color.White) ) } } } @Composable -private fun TelegramLogoutItem( - onClick: () -> Unit, - isDarkTheme: Boolean -) { +private fun TelegramLogoutItem(onClick: () -> Unit, isDarkTheme: Boolean) { val redColor = if (isDarkTheme) Color(0xFFFF5555) else Color(0xFFFF3B30) Row( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onClick) - .padding(horizontal = 16.dp, vertical = 14.dp), - verticalAlignment = Alignment.CenterVertically + modifier = + Modifier.fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically ) { Icon( - imageVector = TablerIcons.Logout, - contentDescription = null, - tint = redColor, - modifier = Modifier.size(24.dp) + imageVector = TablerIcons.Logout, + contentDescription = null, + tint = redColor, + modifier = Modifier.size(24.dp) ) Spacer(modifier = Modifier.width(20.dp)) - Text( - text = "Log Out", - fontSize = 16.sp, - color = redColor - ) + Text(text = "Log Out", fontSize = 16.sp, color = redColor) } } @@ -1401,25 +1380,25 @@ private fun ProfileSectionTitle(title: String, isDarkTheme: Boolean) { val textColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF8E8E93) Text( - text = title.uppercase(), - fontSize = 13.sp, - fontWeight = FontWeight.SemiBold, - color = textColor, - modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp) + text = title.uppercase(), + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + color = textColor, + modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp) ) } @Composable fun ProfileNavigationItem( - icon: ImageVector, - iconBackground: Color, - title: String, - subtitle: String, - onClick: () -> Unit, - isDarkTheme: Boolean, - showDivider: Boolean = false, - hideChevron: Boolean = false, - textColor: Color? = null + icon: ImageVector, + iconBackground: Color, + title: String, + subtitle: String, + onClick: () -> Unit, + isDarkTheme: Boolean, + showDivider: Boolean = false, + hideChevron: Boolean = false, + textColor: Color? = null ) { val defaultTextColor = if (isDarkTheme) Color.White else Color.Black val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) @@ -1428,24 +1407,24 @@ fun ProfileNavigationItem( Column { Row( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onClick) - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically + modifier = + Modifier.fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically ) { Box( - modifier = Modifier - .size(36.dp) - .clip(RoundedCornerShape(8.dp)) - .background(iconBackground), - contentAlignment = Alignment.Center + modifier = + Modifier.size(36.dp) + .clip(RoundedCornerShape(8.dp)) + .background(iconBackground), + contentAlignment = Alignment.Center ) { Icon( - imageVector = icon, - contentDescription = null, - tint = Color.White, - modifier = Modifier.size(20.dp) + imageVector = icon, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(20.dp) ) } @@ -1453,34 +1432,30 @@ fun ProfileNavigationItem( Column(modifier = Modifier.weight(1f)) { Text( - text = title, - fontSize = 16.sp, - color = textColor ?: defaultTextColor, - fontWeight = FontWeight.Medium + text = title, + fontSize = 16.sp, + color = textColor ?: defaultTextColor, + fontWeight = FontWeight.Medium ) Spacer(modifier = Modifier.height(2.dp)) - Text( - text = subtitle, - fontSize = 13.sp, - color = secondaryTextColor - ) + Text(text = subtitle, fontSize = 13.sp, color = secondaryTextColor) } if (!hideChevron) { Icon( - imageVector = TablerIcons.ChevronRight, - contentDescription = null, - tint = iconTintColor.copy(alpha = 0.5f), - modifier = Modifier.size(20.dp) + imageVector = TablerIcons.ChevronRight, + contentDescription = null, + tint = iconTintColor.copy(alpha = 0.5f), + modifier = Modifier.size(20.dp) ) } } if (showDivider) { Divider( - color = dividerColor, - thickness = 0.5.dp, - modifier = Modifier.padding(start = 68.dp) + color = dividerColor, + thickness = 0.5.dp, + modifier = Modifier.padding(start = 68.dp) ) } }