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.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
}
)
}
)
}
}
}

View File

@@ -2115,6 +2115,7 @@ fun ChatDetailScreen(
dialogs = dialogsList,
isDarkTheme = isDarkTheme,
currentUserPublicKey = currentUserPublicKey,
avatarRepository = avatarRepository,
onDismiss = {
showForwardPicker = false
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.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
}

View File

@@ -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

View File

@@ -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<DialogUiModel>,
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
)
}

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