feat: implement swipe back navigation and integrate VerifiedBadge in chat dialogs

This commit is contained in:
2026-02-05 03:25:20 +05:00
parent a03e267050
commit 9010d1c975
6 changed files with 555 additions and 295 deletions

View File

@@ -41,6 +41,7 @@ import com.rosetta.messenger.ui.chats.ChatDetailScreen
import com.rosetta.messenger.ui.chats.ChatsListScreen import com.rosetta.messenger.ui.chats.ChatsListScreen
import com.rosetta.messenger.ui.chats.SearchScreen import com.rosetta.messenger.ui.chats.SearchScreen
import com.rosetta.messenger.ui.components.OptimizedEmojiCache import com.rosetta.messenger.ui.components.OptimizedEmojiCache
import com.rosetta.messenger.ui.components.SwipeBackContainer
import com.rosetta.messenger.ui.onboarding.OnboardingScreen import com.rosetta.messenger.ui.onboarding.OnboardingScreen
import com.rosetta.messenger.ui.settings.BackupScreen import com.rosetta.messenger.ui.settings.BackupScreen
import com.rosetta.messenger.ui.settings.BiometricEnableScreen import com.rosetta.messenger.ui.settings.BiometricEnableScreen
@@ -551,18 +552,10 @@ fun MainScreen(
// Coroutine scope for profile updates // Coroutine scope for profile updates
val mainScreenScope = rememberCoroutineScope() val mainScreenScope = rememberCoroutineScope()
// 🔥 Простая навигация с fade-in анимацией // 🔥 Простая навигация с swipe back
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
// Base layer - chats list // Base layer - chats list (всегда видимый, чтобы его было видно при свайпе)
androidx.compose.animation.AnimatedVisibility( ChatsListScreen(
visible = !showBackupScreen && !showSafetyScreen && !showThemeScreen &&
!showUpdatesScreen && selectedUser == null && !showSearchScreen &&
!showProfileScreen && !showOtherProfileScreen && !showLogsScreen &&
!showCrashLogsScreen && !showBiometricScreen,
enter = fadeIn(animationSpec = tween(300)),
exit = fadeOut(animationSpec = tween(200))
) {
ChatsListScreen(
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
accountName = accountName, accountName = accountName,
accountUsername = accountUsername, accountUsername = accountUsername,
@@ -606,115 +599,115 @@ fun MainScreen(
avatarRepository = avatarRepository, avatarRepository = avatarRepository,
onLogout = onLogout onLogout = onLogout
) )
}
// Other screens with fade animation // Other screens with swipe back
androidx.compose.animation.AnimatedVisibility( SwipeBackContainer(
visible = showBackupScreen, isVisible = showBackupScreen,
enter = fadeIn(animationSpec = tween(300)), onBack = {
exit = fadeOut(animationSpec = tween(200)) showBackupScreen = false
showSafetyScreen = true
},
isDarkTheme = isDarkTheme
) { ) {
if (showBackupScreen) { BackupScreen(
BackupScreen( isDarkTheme = isDarkTheme,
isDarkTheme = isDarkTheme, onBack = {
onBack = { showBackupScreen = false
showBackupScreen = false showSafetyScreen = true
showSafetyScreen = true },
}, onVerifyPassword = { password ->
onVerifyPassword = { password -> // Verify password by trying to decrypt the private key
// Verify password by trying to decrypt the private key try {
try { val publicKey = account?.publicKey ?: return@BackupScreen null
val publicKey = account?.publicKey ?: return@BackupScreen null val accountManager = AccountManager(context)
val accountManager = AccountManager(context) val encryptedAccount = accountManager.getAccount(publicKey)
val encryptedAccount = accountManager.getAccount(publicKey)
if (encryptedAccount != null) {
if (encryptedAccount != null) { // Try to decrypt private key with password
// Try to decrypt private key with password val decryptedPrivateKey = com.rosetta.messenger.crypto.CryptoManager.decryptWithPassword(
val decryptedPrivateKey = com.rosetta.messenger.crypto.CryptoManager.decryptWithPassword( encryptedAccount.encryptedPrivateKey,
encryptedAccount.encryptedPrivateKey, password
)
if (decryptedPrivateKey != null) {
// Password is correct, decrypt seed phrase
com.rosetta.messenger.crypto.CryptoManager.decryptWithPassword(
encryptedAccount.encryptedSeedPhrase,
password password
) )
if (decryptedPrivateKey != null) {
// Password is correct, decrypt seed phrase
com.rosetta.messenger.crypto.CryptoManager.decryptWithPassword(
encryptedAccount.encryptedSeedPhrase,
password
)
} else {
null
}
} else { } else {
null null
} }
} catch (e: Exception) { } else {
null null
} }
} catch (e: Exception) {
null
} }
) }
} )
} }
androidx.compose.animation.AnimatedVisibility( SwipeBackContainer(
visible = showSafetyScreen, isVisible = showSafetyScreen,
enter = fadeIn(animationSpec = tween(300)), onBack = {
exit = fadeOut(animationSpec = tween(200)) showSafetyScreen = false
showProfileScreen = true
},
isDarkTheme = isDarkTheme
) { ) {
if (showSafetyScreen) { SafetyScreen(
SafetyScreen( isDarkTheme = isDarkTheme,
isDarkTheme = isDarkTheme, accountPublicKey = accountPublicKey,
accountPublicKey = accountPublicKey, accountPrivateKey = accountPrivateKey,
accountPrivateKey = accountPrivateKey, onBack = {
onBack = { showSafetyScreen = false
showSafetyScreen = false showProfileScreen = true
showProfileScreen = true },
}, onBackupClick = {
onBackupClick = { showSafetyScreen = false
showSafetyScreen = false showBackupScreen = true
showBackupScreen = true },
}, onDeleteAccount = {
onDeleteAccount = { // TODO: Implement account deletion
// TODO: Implement account deletion }
} )
)
}
} }
androidx.compose.animation.AnimatedVisibility( SwipeBackContainer(
visible = showThemeScreen, isVisible = showThemeScreen,
enter = fadeIn(animationSpec = tween(300)), onBack = {
exit = fadeOut(animationSpec = tween(200)) showThemeScreen = false
showProfileScreen = true
},
isDarkTheme = isDarkTheme
) { ) {
if (showThemeScreen) { ThemeScreen(
ThemeScreen( isDarkTheme = isDarkTheme,
isDarkTheme = isDarkTheme, currentThemeMode = themeMode,
currentThemeMode = themeMode, onBack = {
onBack = { showThemeScreen = false
showThemeScreen = false showProfileScreen = true
showProfileScreen = true },
}, onThemeModeChange = onThemeModeChange
onThemeModeChange = onThemeModeChange )
)
}
} }
androidx.compose.animation.AnimatedVisibility( SwipeBackContainer(
visible = showUpdatesScreen, isVisible = showUpdatesScreen,
enter = fadeIn(animationSpec = tween(300)), onBack = { showUpdatesScreen = false },
exit = fadeOut(animationSpec = tween(200)) isDarkTheme = isDarkTheme
) { ) {
if (showUpdatesScreen) { UpdatesScreen(
UpdatesScreen( isDarkTheme = isDarkTheme,
isDarkTheme = isDarkTheme, onBack = { showUpdatesScreen = false }
onBack = { showUpdatesScreen = false } )
)
}
} }
androidx.compose.animation.AnimatedVisibility( SwipeBackContainer(
visible = selectedUser != null, isVisible = selectedUser != null,
enter = fadeIn(animationSpec = tween(300)), onBack = { selectedUser = null },
exit = fadeOut(animationSpec = tween(200)) isDarkTheme = isDarkTheme
) { ) {
if (selectedUser != null) { if (selectedUser != null) {
// Экран чата // Экран чата
@@ -738,122 +731,123 @@ fun MainScreen(
} }
} }
androidx.compose.animation.AnimatedVisibility( SwipeBackContainer(
visible = showSearchScreen, isVisible = showSearchScreen,
enter = fadeIn(animationSpec = tween(300)), onBack = { showSearchScreen = false },
exit = fadeOut(animationSpec = tween(200)) isDarkTheme = isDarkTheme
) { ) {
if (showSearchScreen) { // Экран поиска
// Экран поиска SearchScreen(
SearchScreen( privateKeyHash = privateKeyHash,
privateKeyHash = privateKeyHash, currentUserPublicKey = accountPublicKey,
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(
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
logs = profileState.logs, protocolState = protocolState,
onBack = { onBackClick = { showSearchScreen = false },
showLogsScreen = false onUserSelect = { selectedSearchUser ->
showProfileScreen = true 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 = { onLogout = onLogout,
profileViewModel.clearLogs() 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( SwipeBackContainer(
visible = showCrashLogsScreen, isVisible = showLogsScreen,
enter = fadeIn(animationSpec = tween(300)), onBack = {
exit = fadeOut(animationSpec = tween(200)) showLogsScreen = false
showProfileScreen = true
},
isDarkTheme = isDarkTheme
) { ) {
if (showCrashLogsScreen) { com.rosetta.messenger.ui.settings.ProfileLogsScreen(
CrashLogsScreen( isDarkTheme = isDarkTheme,
onBackClick = { logs = profileState.logs,
showCrashLogsScreen = false onBack = {
showProfileScreen = true showLogsScreen = false
} showProfileScreen = true
) },
} onClearLogs = {
profileViewModel.clearLogs()
}
)
} }
androidx.compose.animation.AnimatedVisibility( SwipeBackContainer(
visible = showOtherProfileScreen, isVisible = showCrashLogsScreen,
enter = fadeIn(animationSpec = tween(300)), onBack = {
exit = fadeOut(animationSpec = tween(200)) 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( OtherProfileScreen(
user = selectedOtherUser!!, user = selectedOtherUser!!,
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
@@ -867,47 +861,48 @@ fun MainScreen(
} }
// Biometric Enable Screen // Biometric Enable Screen
androidx.compose.animation.AnimatedVisibility( SwipeBackContainer(
visible = showBiometricScreen, isVisible = showBiometricScreen,
enter = fadeIn(animationSpec = tween(300)), onBack = {
exit = fadeOut(animationSpec = tween(200)) showBiometricScreen = false
showProfileScreen = true
},
isDarkTheme = isDarkTheme
) { ) {
if (showBiometricScreen) { val biometricManager = remember { com.rosetta.messenger.biometric.BiometricAuthManager(context) }
val biometricManager = remember { com.rosetta.messenger.biometric.BiometricAuthManager(context) } val biometricPrefs = remember { com.rosetta.messenger.biometric.BiometricPreferences(context) }
val biometricPrefs = remember { com.rosetta.messenger.biometric.BiometricPreferences(context) } val activity = context as? FragmentActivity
val activity = context as? FragmentActivity
BiometricEnableScreen( BiometricEnableScreen(
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
onBack = { onBack = {
showBiometricScreen = false showBiometricScreen = false
showProfileScreen = true showProfileScreen = true
}, },
onEnable = { password, onSuccess, onError -> onEnable = { password, onSuccess, onError ->
if (activity == null) { if (activity == null) {
onError("Activity not available") onError("Activity not available")
return@BiometricEnableScreen 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
}
)
} }
)
} 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
}
)
}
)
} }
} }
} }

View File

@@ -2115,6 +2115,7 @@ fun ChatDetailScreen(
dialogs = dialogsList, dialogs = dialogsList,
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
currentUserPublicKey = currentUserPublicKey, currentUserPublicKey = currentUserPublicKey,
avatarRepository = avatarRepository,
onDismiss = { onDismiss = {
showForwardPicker = false showForwardPicker = false
ForwardManager.clear() ForwardManager.clear()

View File

@@ -46,6 +46,7 @@ import com.rosetta.messenger.network.ProtocolState
import com.rosetta.messenger.ui.components.AppleEmojiText import com.rosetta.messenger.ui.components.AppleEmojiText
import com.rosetta.messenger.ui.components.AvatarImage import com.rosetta.messenger.ui.components.AvatarImage
import com.rosetta.messenger.ui.components.BlurredAvatarBackground import com.rosetta.messenger.ui.components.BlurredAvatarBackground
import com.rosetta.messenger.ui.components.VerifiedBadge
import com.rosetta.messenger.ui.onboarding.PrimaryBlue import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
@@ -1807,15 +1808,23 @@ fun DialogItemContent(
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text( Row(
text = displayName, modifier = Modifier.weight(1f),
fontWeight = FontWeight.SemiBold, verticalAlignment = Alignment.CenterVertically
fontSize = 16.sp, ) {
color = textColor, Text(
maxLines = 1, text = displayName,
overflow = TextOverflow.Ellipsis, fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(1f) 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( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -1932,6 +1941,7 @@ fun DialogItemContent(
dialog.lastMessageAttachmentType == "Photo" -> "Photo" dialog.lastMessageAttachmentType == "Photo" -> "Photo"
dialog.lastMessageAttachmentType == "File" -> "File" dialog.lastMessageAttachmentType == "File" -> "File"
dialog.lastMessageAttachmentType == "Avatar" -> "Avatar" dialog.lastMessageAttachmentType == "Avatar" -> "Avatar"
dialog.lastMessageAttachmentType == "Forwarded" -> "Forwarded message"
dialog.lastMessage.isEmpty() -> "No messages" dialog.lastMessage.isEmpty() -> "No messages"
else -> dialog.lastMessage else -> dialog.lastMessage
} }

View File

@@ -174,6 +174,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
val type = firstAttachment.optInt("type", -1) val type = firstAttachment.optInt("type", -1)
when (type) { when (type) {
0 -> "Photo" // AttachmentType.IMAGE = 0 0 -> "Photo" // AttachmentType.IMAGE = 0
1 -> "Forwarded" // AttachmentType.MESSAGES = 1
2 -> "File" // AttachmentType.FILE = 2 2 -> "File" // AttachmentType.FILE = 2
3 -> "Avatar" // AttachmentType.AVATAR = 3 3 -> "Avatar" // AttachmentType.AVATAR = 3
else -> null else -> null
@@ -261,6 +262,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
val type = firstAttachment.optInt("type", -1) val type = firstAttachment.optInt("type", -1)
when (type) { when (type) {
0 -> "Photo" // AttachmentType.IMAGE = 0 0 -> "Photo" // AttachmentType.IMAGE = 0
1 -> "Forwarded" // AttachmentType.MESSAGES = 1
2 -> "File" // AttachmentType.FILE = 2 2 -> "File" // AttachmentType.FILE = 2
3 -> "Avatar" // AttachmentType.AVATAR = 3 3 -> "Avatar" // AttachmentType.AVATAR = 3
else -> null else -> null

View File

@@ -1,5 +1,6 @@
package com.rosetta.messenger.ui.chats package com.rosetta.messenger.ui.chats
import android.view.HapticFeedbackConstants
import androidx.compose.animation.core.* import androidx.compose.animation.core.*
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@@ -17,11 +18,15 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.rosetta.messenger.data.ForwardManager 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 com.rosetta.messenger.ui.onboarding.PrimaryBlue
import java.util.* import java.util.*
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -39,11 +44,13 @@ fun ForwardChatPickerBottomSheet(
dialogs: List<DialogUiModel>, dialogs: List<DialogUiModel>,
isDarkTheme: Boolean, isDarkTheme: Boolean,
currentUserPublicKey: String, currentUserPublicKey: String,
avatarRepository: AvatarRepository? = null,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onChatSelected: (DialogUiModel) -> Unit onChatSelected: (DialogUiModel) -> Unit
) { ) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false) val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val view = LocalView.current
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color.White val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color.White
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
@@ -53,6 +60,11 @@ fun ForwardChatPickerBottomSheet(
val forwardMessages by ForwardManager.forwardMessages.collectAsState() val forwardMessages by ForwardManager.forwardMessages.collectAsState()
val messagesCount = forwardMessages.size val messagesCount = forwardMessages.size
// 🔥 Haptic feedback при открытии
LaunchedEffect(Unit) {
view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
}
// 🔥 Функция для красивого закрытия с анимацией // 🔥 Функция для красивого закрытия с анимацией
fun dismissWithAnimation() { fun dismissWithAnimation() {
scope.launch { scope.launch {
@@ -65,6 +77,7 @@ fun ForwardChatPickerBottomSheet(
onDismissRequest = { dismissWithAnimation() }, onDismissRequest = { dismissWithAnimation() },
sheetState = sheetState, sheetState = sheetState,
containerColor = backgroundColor, containerColor = backgroundColor,
scrimColor = Color.Black.copy(alpha = 0.6f), // 🔥 Более тёмный overlay - перекрывает status bar
dragHandle = { dragHandle = {
// Кастомный handle // Кастомный handle
Column( Column(
@@ -85,7 +98,8 @@ fun ForwardChatPickerBottomSheet(
Spacer(modifier = Modifier.height(16.dp)) 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()) { Column(modifier = Modifier.fillMaxWidth().navigationBarsPadding()) {
// Header // Header
@@ -170,6 +184,7 @@ fun ForwardChatPickerBottomSheet(
dialog = dialog, dialog = dialog,
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
isSavedMessages = dialog.opponentKey == currentUserPublicKey, isSavedMessages = dialog.opponentKey == currentUserPublicKey,
avatarRepository = avatarRepository,
onClick = { onChatSelected(dialog) } onClick = { onChatSelected(dialog) }
) )
@@ -197,16 +212,12 @@ private fun ForwardDialogItem(
dialog: DialogUiModel, dialog: DialogUiModel,
isDarkTheme: Boolean, isDarkTheme: Boolean,
isSavedMessages: Boolean = false, isSavedMessages: Boolean = false,
avatarRepository: AvatarRepository? = null,
onClick: () -> Unit onClick: () -> Unit
) { ) {
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
val avatarColors =
remember(dialog.opponentKey, isDarkTheme) {
getAvatarColor(dialog.opponentKey, isDarkTheme)
}
val displayName = val displayName =
remember(dialog.opponentTitle, dialog.opponentUsername, dialog.opponentKey, isSavedMessages) { remember(dialog.opponentTitle, dialog.opponentUsername, dialog.opponentKey, isSavedMessages) {
when { when {
@@ -217,21 +228,14 @@ private fun ForwardDialogItem(
} }
} }
val initials = // Display name for avatar initials
remember(dialog.opponentTitle, dialog.opponentUsername, dialog.opponentKey, isSavedMessages) { val avatarDisplayName = remember(dialog.opponentTitle, dialog.opponentUsername) {
when { when {
isSavedMessages -> "📁" dialog.opponentTitle.isNotEmpty() -> dialog.opponentTitle
dialog.opponentTitle.isNotEmpty() -> { dialog.opponentUsername.isNotEmpty() -> dialog.opponentUsername
dialog.opponentTitle else -> null
.split(" ") }
.take(2) }
.mapNotNull { it.firstOrNull()?.uppercase() }
.joinToString("")
}
dialog.opponentUsername.isNotEmpty() -> dialog.opponentUsername.take(2).uppercase()
else -> dialog.opponentKey.take(2).uppercase()
}
}
Row( Row(
modifier = modifier =
@@ -240,22 +244,34 @@ private fun ForwardDialogItem(
.padding(horizontal = 16.dp, vertical = 12.dp), .padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
// Avatar // Avatar with real image support
Box( if (isSavedMessages) {
modifier = // Saved Messages - special icon
Modifier.size(48.dp) val avatarColors = remember(dialog.opponentKey, isDarkTheme) {
.clip(CircleShape) getAvatarColor(dialog.opponentKey, isDarkTheme)
.background( }
if (isSavedMessages) PrimaryBlue.copy(alpha = 0.15f) Box(
else avatarColors.backgroundColor modifier = Modifier
), .size(48.dp)
contentAlignment = Alignment.Center .clip(CircleShape)
) { .background(PrimaryBlue.copy(alpha = 0.15f)),
Text( contentAlignment = Alignment.Center
text = initials, ) {
color = if (isSavedMessages) PrimaryBlue else avatarColors.textColor, Text(
fontWeight = FontWeight.SemiBold, text = "📁",
fontSize = 16.sp 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)) Spacer(modifier = Modifier.height(2.dp))
Text( // 📎 Определяем текст превью с учетом attachments
text = val previewText = when {
if (isSavedMessages) "Your personal notes" isSavedMessages -> "Your personal notes"
else dialog.lastMessage.ifEmpty { "No messages" }, 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, fontSize = 14.sp,
color = secondaryTextColor, color = secondaryTextColor,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis enableLinks = false
) )
} }

View File

@@ -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()
}
}
}