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

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

View File

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