diff --git a/app/src/main/java/com/rosetta/messenger/MainActivity.kt b/app/src/main/java/com/rosetta/messenger/MainActivity.kt index ae78f4b..099d9ec 100644 --- a/app/src/main/java/com/rosetta/messenger/MainActivity.kt +++ b/app/src/main/java/com/rosetta/messenger/MainActivity.kt @@ -41,6 +41,7 @@ import com.rosetta.messenger.ui.chats.ChatDetailScreen import com.rosetta.messenger.ui.chats.ChatsListScreen import com.rosetta.messenger.ui.chats.SearchScreen import com.rosetta.messenger.ui.components.OptimizedEmojiCache +import com.rosetta.messenger.ui.components.SwipeBackContainer import com.rosetta.messenger.ui.onboarding.OnboardingScreen import com.rosetta.messenger.ui.settings.BackupScreen import com.rosetta.messenger.ui.settings.BiometricEnableScreen @@ -551,18 +552,10 @@ fun MainScreen( // Coroutine scope for profile updates val mainScreenScope = rememberCoroutineScope() - // 🔥 Простая навигация с fade-in анимацией + // 🔥 Простая навигация с swipe back Box(modifier = Modifier.fillMaxSize()) { - // Base layer - chats list - androidx.compose.animation.AnimatedVisibility( - visible = !showBackupScreen && !showSafetyScreen && !showThemeScreen && - !showUpdatesScreen && selectedUser == null && !showSearchScreen && - !showProfileScreen && !showOtherProfileScreen && !showLogsScreen && - !showCrashLogsScreen && !showBiometricScreen, - enter = fadeIn(animationSpec = tween(300)), - exit = fadeOut(animationSpec = tween(200)) - ) { - ChatsListScreen( + // Base layer - chats list (всегда видимый, чтобы его было видно при свайпе) + ChatsListScreen( isDarkTheme = isDarkTheme, accountName = accountName, accountUsername = accountUsername, @@ -606,115 +599,115 @@ fun MainScreen( avatarRepository = avatarRepository, onLogout = onLogout ) - } - // Other screens with fade animation - androidx.compose.animation.AnimatedVisibility( - visible = showBackupScreen, - enter = fadeIn(animationSpec = tween(300)), - exit = fadeOut(animationSpec = tween(200)) + // Other screens with swipe back + SwipeBackContainer( + isVisible = showBackupScreen, + onBack = { + showBackupScreen = false + showSafetyScreen = true + }, + isDarkTheme = isDarkTheme ) { - if (showBackupScreen) { - BackupScreen( - isDarkTheme = isDarkTheme, - onBack = { - showBackupScreen = false - showSafetyScreen = true - }, - onVerifyPassword = { password -> - // Verify password by trying to decrypt the private key - try { - val publicKey = account?.publicKey ?: return@BackupScreen null - val accountManager = AccountManager(context) - val encryptedAccount = accountManager.getAccount(publicKey) - - if (encryptedAccount != null) { - // Try to decrypt private key with password - val decryptedPrivateKey = com.rosetta.messenger.crypto.CryptoManager.decryptWithPassword( - encryptedAccount.encryptedPrivateKey, + BackupScreen( + isDarkTheme = isDarkTheme, + onBack = { + showBackupScreen = false + showSafetyScreen = true + }, + onVerifyPassword = { password -> + // Verify password by trying to decrypt the private key + try { + val publicKey = account?.publicKey ?: return@BackupScreen null + val accountManager = AccountManager(context) + val encryptedAccount = accountManager.getAccount(publicKey) + + if (encryptedAccount != null) { + // Try to decrypt private key with password + val decryptedPrivateKey = com.rosetta.messenger.crypto.CryptoManager.decryptWithPassword( + encryptedAccount.encryptedPrivateKey, + password + ) + + if (decryptedPrivateKey != null) { + // Password is correct, decrypt seed phrase + com.rosetta.messenger.crypto.CryptoManager.decryptWithPassword( + encryptedAccount.encryptedSeedPhrase, password ) - - if (decryptedPrivateKey != null) { - // Password is correct, decrypt seed phrase - com.rosetta.messenger.crypto.CryptoManager.decryptWithPassword( - encryptedAccount.encryptedSeedPhrase, - password - ) - } else { - null - } } else { null } - } catch (e: Exception) { + } else { null } + } catch (e: Exception) { + null } - ) - } + } + ) } - androidx.compose.animation.AnimatedVisibility( - visible = showSafetyScreen, - enter = fadeIn(animationSpec = tween(300)), - exit = fadeOut(animationSpec = tween(200)) + SwipeBackContainer( + isVisible = showSafetyScreen, + onBack = { + showSafetyScreen = false + showProfileScreen = true + }, + isDarkTheme = isDarkTheme ) { - if (showSafetyScreen) { - SafetyScreen( - isDarkTheme = isDarkTheme, - accountPublicKey = accountPublicKey, - accountPrivateKey = accountPrivateKey, - onBack = { - showSafetyScreen = false - showProfileScreen = true - }, - onBackupClick = { - showSafetyScreen = false - showBackupScreen = true - }, - onDeleteAccount = { - // TODO: Implement account deletion - } - ) - } + SafetyScreen( + isDarkTheme = isDarkTheme, + accountPublicKey = accountPublicKey, + accountPrivateKey = accountPrivateKey, + onBack = { + showSafetyScreen = false + showProfileScreen = true + }, + onBackupClick = { + showSafetyScreen = false + showBackupScreen = true + }, + onDeleteAccount = { + // TODO: Implement account deletion + } + ) } - androidx.compose.animation.AnimatedVisibility( - visible = showThemeScreen, - enter = fadeIn(animationSpec = tween(300)), - exit = fadeOut(animationSpec = tween(200)) + SwipeBackContainer( + isVisible = showThemeScreen, + onBack = { + showThemeScreen = false + showProfileScreen = true + }, + isDarkTheme = isDarkTheme ) { - if (showThemeScreen) { - ThemeScreen( - isDarkTheme = isDarkTheme, - currentThemeMode = themeMode, - onBack = { - showThemeScreen = false - showProfileScreen = true - }, - onThemeModeChange = onThemeModeChange - ) - } + ThemeScreen( + isDarkTheme = isDarkTheme, + currentThemeMode = themeMode, + onBack = { + showThemeScreen = false + showProfileScreen = true + }, + onThemeModeChange = onThemeModeChange + ) } - androidx.compose.animation.AnimatedVisibility( - visible = showUpdatesScreen, - enter = fadeIn(animationSpec = tween(300)), - exit = fadeOut(animationSpec = tween(200)) + SwipeBackContainer( + isVisible = showUpdatesScreen, + onBack = { showUpdatesScreen = false }, + isDarkTheme = isDarkTheme ) { - if (showUpdatesScreen) { - UpdatesScreen( - isDarkTheme = isDarkTheme, - onBack = { showUpdatesScreen = false } - ) - } + UpdatesScreen( + isDarkTheme = isDarkTheme, + onBack = { showUpdatesScreen = false } + ) } - androidx.compose.animation.AnimatedVisibility( - visible = selectedUser != null, - enter = fadeIn(animationSpec = tween(300)), - exit = fadeOut(animationSpec = tween(200)) + SwipeBackContainer( + isVisible = selectedUser != null, + onBack = { selectedUser = null }, + isDarkTheme = isDarkTheme ) { if (selectedUser != null) { // Экран чата @@ -738,122 +731,123 @@ fun MainScreen( } } - androidx.compose.animation.AnimatedVisibility( - visible = showSearchScreen, - enter = fadeIn(animationSpec = tween(300)), - exit = fadeOut(animationSpec = tween(200)) + SwipeBackContainer( + isVisible = showSearchScreen, + onBack = { showSearchScreen = false }, + isDarkTheme = isDarkTheme ) { - if (showSearchScreen) { - // Экран поиска - SearchScreen( - privateKeyHash = privateKeyHash, - currentUserPublicKey = accountPublicKey, - isDarkTheme = isDarkTheme, - protocolState = protocolState, - onBackClick = { showSearchScreen = false }, - onUserSelect = { selectedSearchUser -> - showSearchScreen = false - selectedUser = selectedSearchUser - } - ) - } - } - - androidx.compose.animation.AnimatedVisibility( - visible = showProfileScreen, - enter = fadeIn(animationSpec = tween(300)), - exit = fadeOut(animationSpec = tween(200)) - ) { - if (showProfileScreen) { - // Экран профиля - ProfileScreen( - isDarkTheme = isDarkTheme, - accountName = accountName, - accountUsername = accountUsername, - accountPublicKey = accountPublicKey, - accountPrivateKeyHash = privateKeyHash, - onBack = { showProfileScreen = false }, - onSaveProfile = { name, username -> - // Following desktop version pattern: - // 1. Server confirms save (handled in ProfileViewModel) - // 2. Local DB updated (handled in ProfileScreen LaunchedEffect) - // 3. This callback updates UI state immediately - accountName = name - accountUsername = username - // Reload account list so auth screen shows updated name - mainScreenScope.launch { - onAccountInfoUpdated() - } - }, - onLogout = onLogout, - onNavigateToTheme = { - showProfileScreen = false - showThemeScreen = true - }, - onNavigateToSafety = { - showProfileScreen = false - showSafetyScreen = true - }, - onNavigateToLogs = { - showProfileScreen = false - showLogsScreen = true - }, - onNavigateToCrashLogs = { - showProfileScreen = false - showCrashLogsScreen = true - }, - onNavigateToBiometric = { - showProfileScreen = false - showBiometricScreen = true - }, - viewModel = profileViewModel, - avatarRepository = avatarRepository, - dialogDao = RosettaDatabase.getDatabase(context).dialogDao() - ) - } - } - - androidx.compose.animation.AnimatedVisibility( - visible = showLogsScreen, - enter = fadeIn(animationSpec = tween(300)), - exit = fadeOut(animationSpec = tween(200)) - ) { - if (showLogsScreen) { - com.rosetta.messenger.ui.settings.ProfileLogsScreen( + // Экран поиска + SearchScreen( + privateKeyHash = privateKeyHash, + currentUserPublicKey = accountPublicKey, isDarkTheme = isDarkTheme, - logs = profileState.logs, - onBack = { - showLogsScreen = false - showProfileScreen = true + protocolState = protocolState, + onBackClick = { showSearchScreen = false }, + onUserSelect = { selectedSearchUser -> + showSearchScreen = false + selectedUser = selectedSearchUser + } + ) + } + + SwipeBackContainer( + isVisible = showProfileScreen, + onBack = { showProfileScreen = false }, + isDarkTheme = isDarkTheme + ) { + // Экран профиля + ProfileScreen( + isDarkTheme = isDarkTheme, + accountName = accountName, + accountUsername = accountUsername, + accountPublicKey = accountPublicKey, + accountPrivateKeyHash = privateKeyHash, + onBack = { showProfileScreen = false }, + onSaveProfile = { name, username -> + // Following desktop version pattern: + // 1. Server confirms save (handled in ProfileViewModel) + // 2. Local DB updated (handled in ProfileScreen LaunchedEffect) + // 3. This callback updates UI state immediately + accountName = name + accountUsername = username + // Reload account list so auth screen shows updated name + mainScreenScope.launch { + onAccountInfoUpdated() + } }, - onClearLogs = { - profileViewModel.clearLogs() - } - ) - } + onLogout = onLogout, + onNavigateToTheme = { + showProfileScreen = false + showThemeScreen = true + }, + onNavigateToSafety = { + showProfileScreen = false + showSafetyScreen = true + }, + onNavigateToLogs = { + showProfileScreen = false + showLogsScreen = true + }, + onNavigateToCrashLogs = { + showProfileScreen = false + showCrashLogsScreen = true + }, + onNavigateToBiometric = { + showProfileScreen = false + showBiometricScreen = true + }, + viewModel = profileViewModel, + avatarRepository = avatarRepository, + dialogDao = RosettaDatabase.getDatabase(context).dialogDao() + ) } - androidx.compose.animation.AnimatedVisibility( - visible = showCrashLogsScreen, - enter = fadeIn(animationSpec = tween(300)), - exit = fadeOut(animationSpec = tween(200)) + SwipeBackContainer( + isVisible = showLogsScreen, + onBack = { + showLogsScreen = false + showProfileScreen = true + }, + isDarkTheme = isDarkTheme ) { - if (showCrashLogsScreen) { - CrashLogsScreen( - onBackClick = { - showCrashLogsScreen = false - showProfileScreen = true - } - ) - } + com.rosetta.messenger.ui.settings.ProfileLogsScreen( + isDarkTheme = isDarkTheme, + logs = profileState.logs, + onBack = { + showLogsScreen = false + showProfileScreen = true + }, + onClearLogs = { + profileViewModel.clearLogs() + } + ) } - androidx.compose.animation.AnimatedVisibility( - visible = showOtherProfileScreen, - enter = fadeIn(animationSpec = tween(300)), - exit = fadeOut(animationSpec = tween(200)) + SwipeBackContainer( + isVisible = showCrashLogsScreen, + onBack = { + showCrashLogsScreen = false + showProfileScreen = true + }, + isDarkTheme = isDarkTheme ) { - if (showOtherProfileScreen && selectedOtherUser != null) { + CrashLogsScreen( + onBackClick = { + showCrashLogsScreen = false + showProfileScreen = true + } + ) + } + + SwipeBackContainer( + isVisible = showOtherProfileScreen, + onBack = { + showOtherProfileScreen = false + selectedOtherUser = null + }, + isDarkTheme = isDarkTheme + ) { + if (selectedOtherUser != null) { OtherProfileScreen( user = selectedOtherUser!!, isDarkTheme = isDarkTheme, @@ -867,47 +861,48 @@ fun MainScreen( } // Biometric Enable Screen - androidx.compose.animation.AnimatedVisibility( - visible = showBiometricScreen, - enter = fadeIn(animationSpec = tween(300)), - exit = fadeOut(animationSpec = tween(200)) + SwipeBackContainer( + isVisible = showBiometricScreen, + onBack = { + showBiometricScreen = false + showProfileScreen = true + }, + isDarkTheme = isDarkTheme ) { - 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 + 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 - } - ) + 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 + } + ) + } + ) } } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt index a940d9e..f36b4c4 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt @@ -2115,6 +2115,7 @@ fun ChatDetailScreen( dialogs = dialogsList, isDarkTheme = isDarkTheme, currentUserPublicKey = currentUserPublicKey, + avatarRepository = avatarRepository, onDismiss = { showForwardPicker = false ForwardManager.clear() diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt index 10bab8c..e52b8f2 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt @@ -46,6 +46,7 @@ import com.rosetta.messenger.network.ProtocolState import com.rosetta.messenger.ui.components.AppleEmojiText import com.rosetta.messenger.ui.components.AvatarImage import com.rosetta.messenger.ui.components.BlurredAvatarBackground +import com.rosetta.messenger.ui.components.VerifiedBadge import com.rosetta.messenger.ui.onboarding.PrimaryBlue import java.text.SimpleDateFormat import java.util.* @@ -1807,15 +1808,23 @@ fun DialogItemContent( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - Text( - text = displayName, - fontWeight = FontWeight.SemiBold, - fontSize = 16.sp, - color = textColor, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f) - ) + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = displayName, + fontWeight = FontWeight.SemiBold, + fontSize = 16.sp, + color = textColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + if (dialog.verified > 0) { + Spacer(modifier = Modifier.width(4.dp)) + VerifiedBadge(verified = dialog.verified, size = 16) + } + } Row( verticalAlignment = Alignment.CenterVertically, @@ -1932,6 +1941,7 @@ fun DialogItemContent( dialog.lastMessageAttachmentType == "Photo" -> "Photo" dialog.lastMessageAttachmentType == "File" -> "File" dialog.lastMessageAttachmentType == "Avatar" -> "Avatar" + dialog.lastMessageAttachmentType == "Forwarded" -> "Forwarded message" dialog.lastMessage.isEmpty() -> "No messages" else -> dialog.lastMessage } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt index 05943d4..32f2d02 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt @@ -174,6 +174,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio val type = firstAttachment.optInt("type", -1) when (type) { 0 -> "Photo" // AttachmentType.IMAGE = 0 + 1 -> "Forwarded" // AttachmentType.MESSAGES = 1 2 -> "File" // AttachmentType.FILE = 2 3 -> "Avatar" // AttachmentType.AVATAR = 3 else -> null @@ -261,6 +262,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio val type = firstAttachment.optInt("type", -1) when (type) { 0 -> "Photo" // AttachmentType.IMAGE = 0 + 1 -> "Forwarded" // AttachmentType.MESSAGES = 1 2 -> "File" // AttachmentType.FILE = 2 3 -> "Avatar" // AttachmentType.AVATAR = 3 else -> null diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ForwardChatPickerBottomSheet.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ForwardChatPickerBottomSheet.kt index 087e186..600a422 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ForwardChatPickerBottomSheet.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ForwardChatPickerBottomSheet.kt @@ -1,5 +1,6 @@ package com.rosetta.messenger.ui.chats +import android.view.HapticFeedbackConstants import androidx.compose.animation.core.* import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -17,11 +18,15 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.rosetta.messenger.data.ForwardManager +import com.rosetta.messenger.repository.AvatarRepository +import com.rosetta.messenger.ui.components.AppleEmojiText +import com.rosetta.messenger.ui.components.AvatarImage import com.rosetta.messenger.ui.onboarding.PrimaryBlue import java.util.* import kotlinx.coroutines.launch @@ -39,11 +44,13 @@ fun ForwardChatPickerBottomSheet( dialogs: List, isDarkTheme: Boolean, currentUserPublicKey: String, + avatarRepository: AvatarRepository? = null, onDismiss: () -> Unit, onChatSelected: (DialogUiModel) -> Unit ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false) val scope = rememberCoroutineScope() + val view = LocalView.current val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color.White val textColor = if (isDarkTheme) Color.White else Color.Black @@ -53,6 +60,11 @@ fun ForwardChatPickerBottomSheet( val forwardMessages by ForwardManager.forwardMessages.collectAsState() val messagesCount = forwardMessages.size + // 🔥 Haptic feedback при открытии + LaunchedEffect(Unit) { + view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) + } + // 🔥 Функция для красивого закрытия с анимацией fun dismissWithAnimation() { scope.launch { @@ -65,6 +77,7 @@ fun ForwardChatPickerBottomSheet( onDismissRequest = { dismissWithAnimation() }, sheetState = sheetState, containerColor = backgroundColor, + scrimColor = Color.Black.copy(alpha = 0.6f), // 🔥 Более тёмный overlay - перекрывает status bar dragHandle = { // Кастомный handle Column( @@ -85,7 +98,8 @@ fun ForwardChatPickerBottomSheet( Spacer(modifier = Modifier.height(16.dp)) } }, - shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp) + shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp), + modifier = Modifier.statusBarsPadding() // 🔥 Учитываем status bar ) { Column(modifier = Modifier.fillMaxWidth().navigationBarsPadding()) { // Header @@ -170,6 +184,7 @@ fun ForwardChatPickerBottomSheet( dialog = dialog, isDarkTheme = isDarkTheme, isSavedMessages = dialog.opponentKey == currentUserPublicKey, + avatarRepository = avatarRepository, onClick = { onChatSelected(dialog) } ) @@ -197,16 +212,12 @@ private fun ForwardDialogItem( dialog: DialogUiModel, isDarkTheme: Boolean, isSavedMessages: Boolean = false, + avatarRepository: AvatarRepository? = null, onClick: () -> Unit ) { val textColor = if (isDarkTheme) Color.White else Color.Black val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) - val avatarColors = - remember(dialog.opponentKey, isDarkTheme) { - getAvatarColor(dialog.opponentKey, isDarkTheme) - } - val displayName = remember(dialog.opponentTitle, dialog.opponentUsername, dialog.opponentKey, isSavedMessages) { when { @@ -217,21 +228,14 @@ private fun ForwardDialogItem( } } - val initials = - remember(dialog.opponentTitle, dialog.opponentUsername, dialog.opponentKey, isSavedMessages) { - when { - isSavedMessages -> "📁" - dialog.opponentTitle.isNotEmpty() -> { - dialog.opponentTitle - .split(" ") - .take(2) - .mapNotNull { it.firstOrNull()?.uppercase() } - .joinToString("") - } - dialog.opponentUsername.isNotEmpty() -> dialog.opponentUsername.take(2).uppercase() - else -> dialog.opponentKey.take(2).uppercase() - } - } + // Display name for avatar initials + val avatarDisplayName = remember(dialog.opponentTitle, dialog.opponentUsername) { + when { + dialog.opponentTitle.isNotEmpty() -> dialog.opponentTitle + dialog.opponentUsername.isNotEmpty() -> dialog.opponentUsername + else -> null + } + } Row( modifier = @@ -240,22 +244,34 @@ private fun ForwardDialogItem( .padding(horizontal = 16.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically ) { - // Avatar - Box( - modifier = - Modifier.size(48.dp) - .clip(CircleShape) - .background( - if (isSavedMessages) PrimaryBlue.copy(alpha = 0.15f) - else avatarColors.backgroundColor - ), - contentAlignment = Alignment.Center - ) { - Text( - text = initials, - color = if (isSavedMessages) PrimaryBlue else avatarColors.textColor, - fontWeight = FontWeight.SemiBold, - fontSize = 16.sp + // Avatar with real image support + if (isSavedMessages) { + // Saved Messages - special icon + val avatarColors = remember(dialog.opponentKey, isDarkTheme) { + getAvatarColor(dialog.opponentKey, isDarkTheme) + } + Box( + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + .background(PrimaryBlue.copy(alpha = 0.15f)), + contentAlignment = Alignment.Center + ) { + Text( + text = "📁", + color = PrimaryBlue, + fontWeight = FontWeight.SemiBold, + fontSize = 16.sp + ) + } + } else { + // Regular user - use AvatarImage with real avatar support + AvatarImage( + publicKey = dialog.opponentKey, + avatarRepository = avatarRepository, + size = 48.dp, + isDarkTheme = isDarkTheme, + displayName = avatarDisplayName ) } @@ -274,14 +290,23 @@ private fun ForwardDialogItem( Spacer(modifier = Modifier.height(2.dp)) - Text( - text = - if (isSavedMessages) "Your personal notes" - else dialog.lastMessage.ifEmpty { "No messages" }, + // 📎 Определяем текст превью с учетом attachments + val previewText = when { + isSavedMessages -> "Your personal notes" + dialog.lastMessageAttachmentType == "Photo" -> "Photo" + dialog.lastMessageAttachmentType == "File" -> "File" + dialog.lastMessageAttachmentType == "Avatar" -> "Avatar" + dialog.lastMessageAttachmentType == "Forwarded" -> "Forwarded message" + dialog.lastMessage.isNotEmpty() -> dialog.lastMessage + else -> "No messages" + } + + AppleEmojiText( + text = previewText, fontSize = 14.sp, color = secondaryTextColor, maxLines = 1, - overflow = TextOverflow.Ellipsis + enableLinks = false ) } diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/SwipeBackContainer.kt b/app/src/main/java/com/rosetta/messenger/ui/components/SwipeBackContainer.kt new file mode 100644 index 0000000..63c3809 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/ui/components/SwipeBackContainer.kt @@ -0,0 +1,227 @@ +package com.rosetta.messenger.ui.components + +import androidx.compose.animation.core.* +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.* +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.* +import androidx.compose.ui.input.pointer.util.VelocityTracker +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch + +// Telegram's CubicBezierInterpolator(0.25, 0.1, 0.25, 1.0) +private val TelegramEasing = CubicBezierEasing(0.25f, 0.1f, 0.25f, 1.0f) + +// Constants matching Telegram +private const val COMPLETION_THRESHOLD = 0.5f // 50% of screen width +private const val FLING_VELOCITY_THRESHOLD = 600f // px/s +private const val ANIMATION_DURATION_ENTER = 300 +private const val ANIMATION_DURATION_EXIT = 250 +private const val EDGE_ZONE_DP = 200 + +/** + * Telegram-style swipe back container (optimized) + * + * Wraps content and allows swiping from the left edge to go back. + * Features: + * - Edge-only swipe detection (left 30dp) + * - Direct state update during drag (no coroutine overhead) + * - VelocityTracker for fling detection + * - Smooth Telegram-style bezier animation + * - Scrim (dimming) on background + * - Shadow on left edge during swipe + * - Threshold: 50% of width OR 600px/s fling velocity + */ +@Composable +fun SwipeBackContainer( + isVisible: Boolean, + onBack: () -> Unit, + isDarkTheme: Boolean, + swipeEnabled: Boolean = true, + content: @Composable () -> Unit +) { + val density = LocalDensity.current + val configuration = LocalConfiguration.current + val screenWidthPx = with(density) { configuration.screenWidthDp.dp.toPx() } + val edgeZonePx = with(density) { EDGE_ZONE_DP.dp.toPx() } + + // Animation state for swipe (used only for swipe animations, not during drag) + val offsetAnimatable = remember { Animatable(0f) } + + // Alpha animation for fade-in entry + val alphaAnimatable = remember { Animatable(0f) } + + // Drag state - direct update without animation + var dragOffset by remember { mutableFloatStateOf(0f) } + var isDragging by remember { mutableStateOf(false) } + + // Visibility state + var shouldShow by remember { mutableStateOf(false) } + var isAnimatingIn by remember { mutableStateOf(false) } + var isAnimatingOut by remember { mutableStateOf(false) } + + // Coroutine scope for animations + val scope = rememberCoroutineScope() + + // Current offset: use drag offset during drag, animatable otherwise + val currentOffset = if (isDragging) dragOffset else offsetAnimatable.value + + // Current alpha: use animatable during fade animations, otherwise 1 + val currentAlpha = if (isAnimatingIn || isAnimatingOut) alphaAnimatable.value else 1f + + // Scrim alpha based on swipe progress + val scrimAlpha = (1f - (currentOffset / screenWidthPx).coerceIn(0f, 1f)) * 0.4f + + // Handle visibility changes + LaunchedEffect(isVisible) { + if (isVisible && !shouldShow) { + // Animate in: fade-in + shouldShow = true + isAnimatingIn = true + offsetAnimatable.snapTo(0f) // No slide for entry + alphaAnimatable.snapTo(0f) + alphaAnimatable.animateTo( + targetValue = 1f, + animationSpec = tween( + durationMillis = ANIMATION_DURATION_ENTER, + easing = FastOutSlowInEasing + ) + ) + isAnimatingIn = false + } else if (!isVisible && shouldShow && !isAnimatingOut) { + // Animate out: fade-out (when triggered by button, not swipe) + isAnimatingOut = true + alphaAnimatable.snapTo(1f) + alphaAnimatable.animateTo( + targetValue = 0f, + animationSpec = tween( + durationMillis = 200, + easing = FastOutSlowInEasing + ) + ) + shouldShow = false + isAnimatingOut = false + offsetAnimatable.snapTo(0f) + alphaAnimatable.snapTo(0f) + dragOffset = 0f + } + } + + if (!shouldShow && !isAnimatingIn && !isAnimatingOut) return + + Box(modifier = Modifier.fillMaxSize()) { + // Scrim (dimming layer behind the screen) - only when swiping + if (currentOffset > 0f) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = scrimAlpha)) + ) + } + + // Content with swipe gesture + Box( + modifier = Modifier + .fillMaxSize() + .graphicsLayer { + translationX = currentOffset + alpha = currentAlpha + } + .background(if (isDarkTheme) Color(0xFF1B1B1B) else Color.White) + .then( + if (swipeEnabled && !isAnimatingIn && !isAnimatingOut) { + Modifier.pointerInput(Unit) { + val velocityTracker = VelocityTracker() + + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + + // Edge-only detection + if (down.position.x > edgeZonePx) { + return@awaitEachGesture + } + + velocityTracker.resetTracking() + var startedSwipe = false + + try { + horizontalDrag(down.id) { change -> + val dragAmount = change.positionChange().x + + // Only start swipe if moving right + if (!startedSwipe && dragAmount > 0) { + startedSwipe = true + isDragging = true + dragOffset = offsetAnimatable.value + } + + if (startedSwipe) { + // Direct state update - NO coroutines! + dragOffset = (dragOffset + dragAmount) + .coerceIn(0f, screenWidthPx) + velocityTracker.addPosition( + change.uptimeMillis, + change.position + ) + change.consume() + } + } + } catch (_: Exception) { + // Gesture was cancelled + } + + // Handle drag end + if (startedSwipe) { + isDragging = false + val velocity = velocityTracker.calculateVelocity().x + val currentProgress = dragOffset / screenWidthPx + + // Telegram logic: fling OR 50% threshold + val shouldComplete = + velocity > FLING_VELOCITY_THRESHOLD || + (currentProgress > COMPLETION_THRESHOLD && + velocity > -FLING_VELOCITY_THRESHOLD) + + // Sync animatable with current drag position and animate + scope.launch { + offsetAnimatable.snapTo(dragOffset) + + if (shouldComplete) { + offsetAnimatable.animateTo( + targetValue = screenWidthPx, + animationSpec = tween( + durationMillis = ANIMATION_DURATION_EXIT, + easing = TelegramEasing + ) + ) + onBack() + } else { + offsetAnimatable.animateTo( + targetValue = 0f, + animationSpec = tween( + durationMillis = ANIMATION_DURATION_EXIT, + easing = TelegramEasing + ) + ) + } + + dragOffset = 0f + } + } + } + } + } else { + Modifier + } + ) + ) { + content() + } + } +}