feat: implement swipe back navigation and integrate VerifiedBadge in chat dialogs
This commit is contained in:
@@ -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
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2115,6 +2115,7 @@ fun ChatDetailScreen(
|
||||
dialogs = dialogsList,
|
||||
isDarkTheme = isDarkTheme,
|
||||
currentUserPublicKey = currentUserPublicKey,
|
||||
avatarRepository = avatarRepository,
|
||||
onDismiss = {
|
||||
showForwardPicker = false
|
||||
ForwardManager.clear()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user