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