Compare commits

..

3 Commits

5 changed files with 414 additions and 154 deletions

View File

@@ -49,6 +49,7 @@ 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.SwipeBackBackgroundEffect import com.rosetta.messenger.ui.components.SwipeBackBackgroundEffect
import com.rosetta.messenger.ui.components.SwipeBackContainer import com.rosetta.messenger.ui.components.SwipeBackContainer
import com.rosetta.messenger.ui.components.SwipeBackEnterAnimation
import com.rosetta.messenger.ui.crashlogs.CrashLogsScreen import com.rosetta.messenger.ui.crashlogs.CrashLogsScreen
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
@@ -1029,6 +1030,7 @@ fun MainScreen(
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
layer = 1, layer = 1,
swipeEnabled = !isChatSwipeLocked, swipeEnabled = !isChatSwipeLocked,
enterAnimation = SwipeBackEnterAnimation.SlideFromRight,
propagateBackgroundProgress = false propagateBackgroundProgress = false
) { ) {
selectedUser?.let { currentChatUser -> selectedUser?.let { currentChatUser ->

View File

@@ -17,8 +17,13 @@ import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.Spring import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.spring import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.SizeTransform import androidx.compose.animation.SizeTransform
@@ -53,6 +58,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.hapticfeedback.HapticFeedbackType
@@ -119,6 +125,8 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
private val groupMembersCountCache = java.util.concurrent.ConcurrentHashMap<String, Int>()
private data class IncomingRunAvatarAccumulator( private data class IncomingRunAvatarAccumulator(
val senderPublicKey: String, val senderPublicKey: String,
val senderDisplayName: String, val senderDisplayName: String,
@@ -515,11 +523,15 @@ fun ChatDetailScreen(
val chatsListViewModel: ChatsListViewModel = viewModel() val chatsListViewModel: ChatsListViewModel = viewModel()
val dialogsList by chatsListViewModel.dialogs.collectAsState() val dialogsList by chatsListViewModel.dialogs.collectAsState()
val groupRepository = remember { GroupRepository.getInstance(context) } val groupRepository = remember { GroupRepository.getInstance(context) }
val groupMembersCacheKey =
remember(user.publicKey, currentUserPublicKey) {
"${currentUserPublicKey.trim()}::${user.publicKey.trim()}"
}
var groupAdminKeys by remember(user.publicKey, currentUserPublicKey) { var groupAdminKeys by remember(user.publicKey, currentUserPublicKey) {
mutableStateOf<Set<String>>(emptySet()) mutableStateOf<Set<String>>(emptySet())
} }
var groupMembersCount by remember(user.publicKey, currentUserPublicKey) { var groupMembersCount by remember(groupMembersCacheKey) {
mutableStateOf<Int?>(null) mutableStateOf<Int?>(groupMembersCountCache[groupMembersCacheKey])
} }
var mentionCandidates by remember(user.publicKey, currentUserPublicKey) { var mentionCandidates by remember(user.publicKey, currentUserPublicKey) {
mutableStateOf<List<MentionCandidate>>(emptyList()) mutableStateOf<List<MentionCandidate>>(emptyList())
@@ -540,14 +552,25 @@ fun ChatDetailScreen(
return@LaunchedEffect return@LaunchedEffect
} }
val cachedMembersCount = groupMembersCountCache[groupMembersCacheKey]
groupMembersCount = cachedMembersCount
val members = withContext(Dispatchers.IO) { val members = withContext(Dispatchers.IO) {
groupRepository.requestGroupMembers(user.publicKey).orEmpty() groupRepository.requestGroupMembers(user.publicKey)
}
if (members == null) {
groupAdminKeys = emptySet()
mentionCandidates = emptyList()
return@LaunchedEffect
} }
val normalizedMembers = val normalizedMembers =
members.map { it.trim() } members.map { it.trim() }
.filter { it.isNotBlank() } .filter { it.isNotBlank() }
.distinct() .distinct()
groupMembersCount = normalizedMembers.size groupMembersCount = normalizedMembers.size
if (normalizedMembers.isNotEmpty()) {
groupMembersCountCache[groupMembersCacheKey] = normalizedMembers.size
}
val adminKey = normalizedMembers.firstOrNull().orEmpty() val adminKey = normalizedMembers.firstOrNull().orEmpty()
groupAdminKeys = groupAdminKeys =
@@ -1006,9 +1029,13 @@ fun ChatDetailScreen(
val isRosettaOfficial = user.title.equals("Rosetta", ignoreCase = true) || val isRosettaOfficial = user.title.equals("Rosetta", ignoreCase = true) ||
user.username.equals("rosetta", ignoreCase = true) || user.username.equals("rosetta", ignoreCase = true) ||
isSystemAccount isSystemAccount
val groupMembersSubtitleCount = groupMembersCount ?: 0 val groupMembersSubtitleCount = groupMembersCount
val groupMembersSubtitle = val groupMembersSubtitle =
"$groupMembersSubtitleCount member${if (groupMembersSubtitleCount == 1) "" else "s"}" if (groupMembersSubtitleCount == null) {
""
} else {
"$groupMembersSubtitleCount member${if (groupMembersSubtitleCount == 1) "" else "s"}"
}
val chatSubtitle = val chatSubtitle =
when { when {
isSavedMessages -> "Notes" isSavedMessages -> "Notes"
@@ -1249,19 +1276,10 @@ fun ChatDetailScreen(
extractCopyableMessageText( extractCopyableMessageText(
msg msg
) )
if (messageText.isBlank()) { messageText
null .takeIf {
} else { it.isNotBlank()
val time = }
SimpleDateFormat(
"HH:mm",
Locale.getDefault()
)
.format(
msg.timestamp
)
"[${if (msg.isOutgoing) "You" else chatTitle}] $time\n$messageText"
}
} }
.joinToString( .joinToString(
"\n\n" "\n\n"
@@ -1582,6 +1600,10 @@ fun ChatDetailScreen(
isDarkTheme = isDarkTheme =
isDarkTheme isDarkTheme
) )
} else if (isGroupChat &&
groupMembersCount == null
) {
GroupMembersSubtitleSkeleton()
} else { } else {
Text( Text(
text = text =
@@ -3597,6 +3619,53 @@ fun ChatDetailScreen(
} // Закрытие outer Box } // Закрытие outer Box
} }
@Composable
private fun GroupMembersSubtitleSkeleton() {
val transition = rememberInfiniteTransition(label = "groupMembersSkeleton")
val shimmerProgress by
transition.animateFloat(
initialValue = -1f,
targetValue = 2f,
animationSpec =
infiniteRepeatable(
animation = tween(1200, easing = LinearEasing),
repeatMode = RepeatMode.Restart
),
label = "groupMembersSkeletonShimmer"
)
BoxWithConstraints(
modifier =
Modifier.padding(top = 2.dp)
.width(76.dp)
.height(12.dp)
) {
val density = LocalDensity.current
val widthPx = with(density) { maxWidth.toPx() }.coerceAtLeast(1f)
val gradientWidthPx = with(density) { 44.dp.toPx() }
val shimmerX = shimmerProgress * widthPx
val shimmerBrush =
Brush.linearGradient(
colorStops =
arrayOf(
0f to Color.White.copy(alpha = 0.22f),
0.5f to Color.White.copy(alpha = 0.56f),
1f to Color.White.copy(alpha = 0.22f)
),
start = Offset(shimmerX - gradientWidthPx, 0f),
end = Offset(shimmerX, 0f)
)
Box(
modifier =
Modifier.fillMaxSize()
.clip(RoundedCornerShape(6.dp))
.background(shimmerBrush)
)
}
}
@Composable @Composable
private fun TiledChatWallpaper( private fun TiledChatWallpaper(
wallpaperResId: Int, wallpaperResId: Int,

View File

@@ -2171,39 +2171,102 @@ fun ChatsListScreen(
} }
} }
if (requestsCount > 0) { item(key = "requests_section") {
item( val isRequestsSectionVisible =
key = requestsCount > 0 &&
"requests_section" isRequestsVisible
AnimatedVisibility(
visible =
isRequestsSectionVisible,
enter =
slideInVertically(
initialOffsetY = {
fullHeight ->
-fullHeight /
3
},
animationSpec =
tween(
durationMillis =
260,
easing =
FastOutSlowInEasing
)
) +
expandVertically(
expandFrom =
Alignment
.Top,
animationSpec =
tween(
durationMillis =
260,
easing =
FastOutSlowInEasing
)
) +
fadeIn(
animationSpec =
tween(
durationMillis =
180
),
initialAlpha =
0.7f
),
exit =
slideOutVertically(
targetOffsetY = {
fullHeight ->
-fullHeight /
3
},
animationSpec =
tween(
durationMillis =
220,
easing =
FastOutSlowInEasing
)
) +
shrinkVertically(
shrinkTowards =
Alignment
.Top,
animationSpec =
tween(
durationMillis =
220,
easing =
FastOutSlowInEasing
)
) +
fadeOut(
animationSpec =
tween(
durationMillis =
140
)
)
) { ) {
AnimatedVisibility( Column {
visible = isRequestsVisible, RequestsSection(
enter = expandVertically( count =
animationSpec = tween(250, easing = FastOutSlowInEasing) requestsCount,
) + fadeIn(animationSpec = tween(200)), requests =
exit = shrinkVertically( requests,
animationSpec = tween(250, easing = FastOutSlowInEasing) isDarkTheme =
) + fadeOut(animationSpec = tween(200)) isDarkTheme,
) { onClick = {
Column { openRequestsRouteSafely()
RequestsSection( }
count = )
requestsCount, Divider(
requests = color =
requests, dividerColor,
isDarkTheme = thickness =
isDarkTheme, 0.5.dp
onClick = { )
openRequestsRouteSafely()
}
)
Divider(
color =
dividerColor,
thickness =
0.5.dp
)
}
} }
} }
} }

View File

@@ -44,6 +44,7 @@ import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowBack
@@ -58,10 +59,10 @@ import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Divider import androidx.compose.material3.Divider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Tab import androidx.compose.material3.Tab
@@ -90,6 +91,7 @@ import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
@@ -107,6 +109,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
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 androidx.compose.ui.window.PopupProperties
import com.airbnb.lottie.compose.LottieAnimation import com.airbnb.lottie.compose.LottieAnimation
import com.airbnb.lottie.compose.LottieCompositionSpec import com.airbnb.lottie.compose.LottieCompositionSpec
import com.airbnb.lottie.compose.LottieConstants import com.airbnb.lottie.compose.LottieConstants
@@ -1007,52 +1010,71 @@ fun GroupInfoScreen(
tint = Color.White tint = Color.White
) )
} }
DropdownMenu( val menuBgColor = if (isDarkTheme) Color(0xFF272829) else Color.White
expanded = showMenu, val menuTextColor = if (isDarkTheme) Color.White else Color(0xFF222222)
onDismissRequest = { showMenu = false } val menuIconColor = if (isDarkTheme) Color.White.copy(alpha = 0.47f) else Color(0xFF676B70)
val menuDividerColor = if (isDarkTheme) Color(0xFF1C1D1F) else Color(0xFFF5F5F5)
val menuDangerColor = Color(0xFFFF3B30)
MaterialTheme(
colorScheme = MaterialTheme.colorScheme.copy(
surface = menuBgColor,
onSurface = menuTextColor
)
) { ) {
DropdownMenuItem( DropdownMenu(
text = { Text("Search members") }, expanded = showMenu,
leadingIcon = { onDismissRequest = { showMenu = false },
Icon(Icons.Default.Search, contentDescription = null) modifier = Modifier.widthIn(min = 196.dp).background(menuBgColor),
}, properties = PopupProperties(
onClick = { focusable = true,
showMenu = false dismissOnBackPress = true,
selectedTab = GroupInfoTab.MEMBERS dismissOnClickOutside = true
showSearch = true )
} ) {
) GroupInfoMenuItem(
DropdownMenuItem( icon = Icons.Default.Search,
text = { Text("Copy invite") }, text = "Search members",
leadingIcon = { tintColor = menuIconColor,
Icon(Icons.Default.PersonAdd, contentDescription = null) textColor = menuTextColor,
}, onClick = {
onClick = { showMenu = false
showMenu = false selectedTab = GroupInfoTab.MEMBERS
copyInvite() showSearch = true
} }
) )
DropdownMenuItem( GroupInfoMenuItem(
text = { Text("Encryption key") }, icon = Icons.Default.PersonAdd,
leadingIcon = { text = "Copy invite",
Icon(Icons.Default.Lock, contentDescription = null) tintColor = menuIconColor,
}, textColor = menuTextColor,
onClick = { onClick = {
showMenu = false showMenu = false
openEncryptionKey() copyInvite()
} }
) )
Divider() GroupInfoMenuItem(
DropdownMenuItem( icon = Icons.Default.Lock,
text = { Text("Leave group", color = Color(0xFFFF3B30)) }, text = "Encryption key",
leadingIcon = { tintColor = menuIconColor,
Icon(Icons.Default.ExitToApp, contentDescription = null, tint = Color(0xFFFF3B30)) textColor = menuTextColor,
}, onClick = {
onClick = { showMenu = false
showMenu = false openEncryptionKey()
showLeaveConfirm = true }
} )
) Divider(color = menuDividerColor)
GroupInfoMenuItem(
icon = Icons.Default.ExitToApp,
text = "Leave group",
tintColor = menuDangerColor,
textColor = menuDangerColor,
onClick = {
showMenu = false
showLeaveConfirm = true
}
)
}
} }
} }
@@ -1158,75 +1180,81 @@ fun GroupInfoScreen(
} }
} }
Spacer(modifier = Modifier.height(8.dp))
Surface( Surface(
modifier = Modifier.fillMaxWidth(), modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
color = sectionColor, color = sectionColor,
shape = RoundedCornerShape(0.dp) shape = RoundedCornerShape(12.dp)
) { ) {
Row( Column {
modifier = Modifier // Add Members
.fillMaxWidth() Row(
.clickable { openInviteSharePicker() } modifier = Modifier
.padding(horizontal = 16.dp, vertical = 12.dp), .fillMaxWidth()
verticalAlignment = Alignment.CenterVertically .clickable { openInviteSharePicker() }
) { .padding(horizontal = 16.dp, vertical = 13.dp),
Icon( verticalAlignment = Alignment.CenterVertically
imageVector = Icons.Default.PersonAdd, ) {
contentDescription = null, Text(
tint = accentColor text = "Add Members",
) color = primaryText,
Spacer(modifier = Modifier.size(12.dp)) fontSize = 16.sp,
Text( modifier = Modifier.weight(1f)
text = "Add Members", )
color = accentColor,
fontSize = 17.sp,
fontWeight = FontWeight.Medium
)
}
}
Surface(
modifier = Modifier.fillMaxWidth(),
color = sectionColor,
shape = RoundedCornerShape(0.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.clickable { openEncryptionKey() }
.padding(horizontal = 16.dp, vertical = 11.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon( Icon(
imageVector = Icons.Default.Lock, imageVector = Icons.Default.PersonAdd,
contentDescription = null, contentDescription = null,
tint = accentColor, tint = accentColor,
modifier = Modifier.size(20.dp) modifier = Modifier.size(22.dp)
) )
Spacer(modifier = Modifier.width(12.dp)) }
Divider(
color = borderColor,
thickness = 0.5.dp,
modifier = Modifier.padding(start = 16.dp)
)
// Encryption Key
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { openEncryptionKey() }
.padding(horizontal = 16.dp, vertical = 13.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text( Text(
text = "Encryption Key", text = "Encryption Key",
color = primaryText, color = primaryText,
fontSize = 16.sp, fontSize = 16.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
if (encryptionKeyLoading) { if (encryptionKeyLoading) {
CircularProgressIndicator( CircularProgressIndicator(
modifier = Modifier.size(16.dp), modifier = Modifier.size(20.dp),
strokeWidth = 2.dp, strokeWidth = 2.dp,
color = accentColor color = accentColor
) )
} else {
val identiconKey = encryptionKey.ifBlank { dialogPublicKey }
Box(
modifier = Modifier
.size(34.dp)
.clip(RoundedCornerShape(6.dp))
) {
TelegramStyleIdenticon(
keyRender = identiconKey,
size = 34.dp,
isDarkTheme = isDarkTheme
)
}
} }
} }
Spacer(modifier = Modifier.height(3.dp))
Text(
text = "Tap to view key used for secure group communication",
color = secondaryText,
fontSize = 12.sp
)
} }
} }
Spacer(modifier = Modifier.height(8.dp))
TabRow( TabRow(
selectedTabIndex = GroupInfoTab.entries.indexOf(selectedTab), selectedTabIndex = GroupInfoTab.entries.indexOf(selectedTab),
@@ -1812,6 +1840,39 @@ private fun GroupEncryptionKeyPage(
} }
} }
@Composable
private fun GroupInfoMenuItem(
icon: ImageVector,
text: String,
tintColor: Color,
textColor: Color,
onClick: () -> Unit
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(48.dp)
.clickable(onClick = onClick)
.padding(horizontal = 18.dp),
contentAlignment = Alignment.CenterStart
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = icon,
contentDescription = null,
tint = tintColor,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(19.dp))
Text(
text = text,
color = textColor,
fontSize = 16.sp
)
}
}
}
@Composable @Composable
private fun GroupActionButton( private fun GroupActionButton(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,

View File

@@ -1,6 +1,7 @@
package com.rosetta.messenger.ui.components package com.rosetta.messenger.ui.components
import android.content.Context import android.content.Context
import android.view.animation.DecelerateInterpolator
import android.view.inputmethod.InputMethodManager import android.view.inputmethod.InputMethodManager
import androidx.compose.animation.core.* import androidx.compose.animation.core.*
import androidx.compose.foundation.background import androidx.compose.foundation.background
@@ -19,15 +20,21 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.isActive
import kotlin.coroutines.coroutineContext
// Telegram's CubicBezierInterpolator(0.25, 0.1, 0.25, 1.0) // Telegram's CubicBezierInterpolator(0.25, 0.1, 0.25, 1.0)
private val TelegramEasing = CubicBezierEasing(0.25f, 0.1f, 0.25f, 1.0f) private val TelegramEasing = CubicBezierEasing(0.25f, 0.1f, 0.25f, 1.0f)
private val TelegramOpenDecelerateEasing =
Easing { input -> DecelerateInterpolator(1.5f).getInterpolation(input) }
// Swipe-back thresholds tuned for ultra-light navigation. // Swipe-back thresholds tuned for ultra-light navigation.
private const val COMPLETION_THRESHOLD = 0.05f // 5% of screen width private const val COMPLETION_THRESHOLD = 0.05f // 5% of screen width
private const val FLING_VELOCITY_THRESHOLD = 35f // px/s private const val FLING_VELOCITY_THRESHOLD = 35f // px/s
private const val ANIMATION_DURATION_ENTER = 300 private const val ANIMATION_DURATION_ENTER = 300
private const val ANIMATION_DURATION_ENTER_TELEGRAM_CHAT = 150
private const val ANIMATION_DURATION_EXIT = 200 private const val ANIMATION_DURATION_EXIT = 200
private const val EDGE_ZONE_DP = 320 private const val EDGE_ZONE_DP = 320
private const val EDGE_ZONE_WIDTH_FRACTION = 0.85f private const val EDGE_ZONE_WIDTH_FRACTION = 0.85f
@@ -36,6 +43,42 @@ private const val HORIZONTAL_DOMINANCE_RATIO = 1.05f
private const val BACKGROUND_MIN_SCALE = 0.97f private const val BACKGROUND_MIN_SCALE = 0.97f
private const val BACKGROUND_PARALLAX_DP = 4 private const val BACKGROUND_PARALLAX_DP = 4
enum class SwipeBackEnterAnimation {
Fade,
SlideFromRight
}
private suspend fun runTelegramChatEnterAnimation(
alphaAnimatable: Animatable<Float, AnimationVector1D>,
enterOffsetAnimatable: Animatable<Float, AnimationVector1D>,
startOffsetPx: Float
) {
var progress = 0f
var lastFrameTimeMs = withFrameNanos { it } / 1_000_000L
while (coroutineContext.isActive && progress < 1f) {
val nowMs = withFrameNanos { it } / 1_000_000L
var dtMs = nowMs - lastFrameTimeMs
if (dtMs > 40L && progress == 0f) {
dtMs = 0L
} else if (dtMs > 18L) {
dtMs = 18L
}
lastFrameTimeMs = nowMs
progress =
(progress + (dtMs / ANIMATION_DURATION_ENTER_TELEGRAM_CHAT.toFloat()))
.coerceIn(0f, 1f)
val interpolated = TelegramOpenDecelerateEasing.transform(progress).coerceIn(0f, 1f)
alphaAnimatable.snapTo(interpolated)
enterOffsetAnimatable.snapTo(startOffsetPx * (1f - interpolated))
}
alphaAnimatable.snapTo(1f)
enterOffsetAnimatable.snapTo(0f)
}
/** /**
* Telegram-style swipe back container (optimized) * Telegram-style swipe back container (optimized)
* *
@@ -89,6 +132,7 @@ fun SwipeBackContainer(
swipeEnabled: Boolean = true, swipeEnabled: Boolean = true,
propagateBackgroundProgress: Boolean = true, propagateBackgroundProgress: Boolean = true,
deferToChildren: Boolean = false, deferToChildren: Boolean = false,
enterAnimation: SwipeBackEnterAnimation = SwipeBackEnterAnimation.Fade,
content: @Composable () -> Unit content: @Composable () -> Unit
) { ) {
// 🚀 Lazy composition: skip ALL setup until the screen is opened for the first time. // 🚀 Lazy composition: skip ALL setup until the screen is opened for the first time.
@@ -103,10 +147,12 @@ fun SwipeBackContainer(
val edgeZonePx = with(density) { EDGE_ZONE_DP.dp.toPx() } val edgeZonePx = with(density) { EDGE_ZONE_DP.dp.toPx() }
val effectiveEdgeZonePx = maxOf(edgeZonePx, screenWidthPx * EDGE_ZONE_WIDTH_FRACTION) val effectiveEdgeZonePx = maxOf(edgeZonePx, screenWidthPx * EDGE_ZONE_WIDTH_FRACTION)
val backgroundParallaxPx = with(density) { BACKGROUND_PARALLAX_DP.dp.toPx() } val backgroundParallaxPx = with(density) { BACKGROUND_PARALLAX_DP.dp.toPx() }
val chatEnterOffsetPx = with(density) { 48.dp.toPx() }
val containerId = remember { System.identityHashCode(Any()).toLong() } val containerId = remember { System.identityHashCode(Any()).toLong() }
// Animation state for swipe (used only for swipe animations, not during drag) // Animation state for swipe (used only for swipe animations, not during drag)
val offsetAnimatable = remember { Animatable(0f) } val offsetAnimatable = remember { Animatable(0f) }
val enterOffsetAnimatable = remember { Animatable(0f) }
// Alpha animation for fade-in entry // Alpha animation for fade-in entry
val alphaAnimatable = remember { Animatable(0f) } val alphaAnimatable = remember { Animatable(0f) }
@@ -129,8 +175,15 @@ fun SwipeBackContainer(
val view = LocalView.current val view = LocalView.current
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
// Current offset: use drag offset during drag, animatable otherwise // Current offset: use drag offset during drag, animatable otherwise + optional enter slide
val currentOffset = if (isDragging) dragOffset else offsetAnimatable.value val baseOffset = if (isDragging) dragOffset else offsetAnimatable.value
val enterOffset =
if (isAnimatingIn && enterAnimation == SwipeBackEnterAnimation.SlideFromRight) {
enterOffsetAnimatable.value
} else {
0f
}
val currentOffset = baseOffset + enterOffset
// Current alpha: use animatable during fade animations, otherwise 1 // Current alpha: use animatable during fade animations, otherwise 1
val currentAlpha = if (isAnimatingIn || isAnimatingOut) alphaAnimatable.value else 1f val currentAlpha = if (isAnimatingIn || isAnimatingOut) alphaAnimatable.value else 1f
@@ -186,17 +239,29 @@ fun SwipeBackContainer(
isAnimatingIn = true isAnimatingIn = true
try { try {
offsetAnimatable.snapTo(0f) // No slide for entry offsetAnimatable.snapTo(0f) // No slide for entry
dragOffset = 0f
alphaAnimatable.snapTo(0f) alphaAnimatable.snapTo(0f)
alphaAnimatable.animateTo( if (enterAnimation == SwipeBackEnterAnimation.SlideFromRight) {
targetValue = 1f, enterOffsetAnimatable.snapTo(chatEnterOffsetPx)
animationSpec = runTelegramChatEnterAnimation(
tween( alphaAnimatable = alphaAnimatable,
durationMillis = ANIMATION_DURATION_ENTER, enterOffsetAnimatable = enterOffsetAnimatable,
easing = FastOutSlowInEasing startOffsetPx = chatEnterOffsetPx
) )
) } else {
enterOffsetAnimatable.snapTo(0f)
alphaAnimatable.animateTo(
targetValue = 1f,
animationSpec =
tween(
durationMillis = ANIMATION_DURATION_ENTER,
easing = FastOutSlowInEasing
)
)
}
} finally { } finally {
isAnimatingIn = false isAnimatingIn = false
enterOffsetAnimatable.snapTo(0f)
} }
} else if (!isVisible && shouldShow) { } else if (!isVisible && shouldShow) {
// Animate out: fade-out (when triggered by button, not swipe) // Animate out: fade-out (when triggered by button, not swipe)
@@ -231,7 +296,7 @@ fun SwipeBackContainer(
} }
) { ) {
// Scrim (dimming layer behind the screen) - only when swiping // Scrim (dimming layer behind the screen) - only when swiping
if (currentOffset > 0f) { if (!isAnimatingIn && currentOffset > 0f) {
Box(modifier = Modifier.fillMaxSize().background(Color.Black.copy(alpha = scrimAlpha))) Box(modifier = Modifier.fillMaxSize().background(Color.Black.copy(alpha = scrimAlpha)))
} }