Доработан UI чатов и звонков (запись ГС, экран звонков, профиль)

This commit is contained in:
2026-04-15 21:27:56 +05:00
parent 060d0cbd12
commit 0d21769399
6 changed files with 204 additions and 80 deletions

View File

@@ -1382,6 +1382,9 @@ fun ChatsListScreen(
}
)
// Keep distance from footer divider so it never overlays Settings.
Spacer(modifier = Modifier.height(8.dp))
}
// ═══════════════════════════════════════════════════════════

View File

@@ -3,7 +3,6 @@ package com.rosetta.messenger.ui.chats.calls
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
@@ -16,7 +15,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Call
import androidx.compose.material.icons.filled.CallMade
@@ -34,10 +33,16 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.platform.LocalContext
import com.airbnb.lottie.compose.LottieAnimation
import com.airbnb.lottie.compose.LottieCompositionSpec
import com.airbnb.lottie.compose.animateLottieCompositionAsState
import com.airbnb.lottie.compose.rememberLottieComposition
import com.rosetta.messenger.R
import com.rosetta.messenger.database.CallHistoryRow
import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.network.SearchUser
@@ -106,17 +111,22 @@ fun CallsHistoryScreen(
LazyColumn(
modifier = modifier.fillMaxSize().background(backgroundColor),
contentPadding = PaddingValues(bottom = 16.dp)
contentPadding = if (items.isEmpty()) PaddingValues(0.dp) else PaddingValues(bottom = 16.dp)
) {
if (items.isEmpty()) {
item(key = "empty_calls") {
Column(
modifier = Modifier.fillParentMaxSize(),
verticalArrangement = Arrangement.Center
) {
EmptyCallsState(
isDarkTheme = isDarkTheme,
title = "No calls yet",
subtitle = "Your call history will appear here",
modifier = Modifier.fillMaxWidth().padding(top = 64.dp)
title = "No Calls Yet",
subtitle = "Your recent voice and video calls will\nappear here.",
modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp)
)
}
}
} else {
items(items, key = { it.messageId }) { item ->
CallHistoryRowItem(
@@ -273,41 +283,65 @@ private fun EmptyCallsState(
subtitle: String,
modifier: Modifier = Modifier
) {
val iconTint = if (isDarkTheme) Color(0xFF5B5C63) else Color(0xFFAFB0B8)
val titleColor = if (isDarkTheme) Color(0xFFE1E1E6) else Color(0xFF1F1F23)
val subtitleColor = if (isDarkTheme) Color(0xFF9D9DA3) else Color(0xFF7A7A80)
val titleColor = if (isDarkTheme) Color(0xFFEDEDF2) else Color(0xFF1C1C1E)
val subtitleColor = if (isDarkTheme) Color(0xFF9D9DA3) else Color(0xFF8E8E93)
val cardColor = if (isDarkTheme) Color(0xFF242529) else Color(0xFFF6F6FA)
val lottieComposition by rememberLottieComposition(
LottieCompositionSpec.RawRes(R.raw.phone_duck)
)
val lottieProgress by animateLottieCompositionAsState(
composition = lottieComposition,
iterations = 1
)
Column(
modifier = modifier.padding(horizontal = 32.dp),
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Box(
modifier = Modifier.size(72.dp).background(iconTint.copy(alpha = 0.2f), CircleShape),
contentAlignment = Alignment.Center
Column(
modifier = Modifier
.fillMaxWidth()
.background(cardColor, RoundedCornerShape(28.dp))
.padding(horizontal = 20.dp, vertical = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
if (lottieComposition != null) {
LottieAnimation(
composition = lottieComposition,
progress = { lottieProgress },
modifier = Modifier.size(184.dp)
)
} else {
Icon(
imageVector = Icons.Default.Call,
contentDescription = null,
tint = iconTint,
modifier = Modifier.size(34.dp)
tint = subtitleColor,
modifier = Modifier.size(52.dp)
)
}
Spacer(modifier = Modifier.height(14.dp))
Spacer(modifier = Modifier.height(18.dp))
if (title.isNotBlank()) {
Text(
text = title,
color = titleColor,
fontSize = 18.sp,
fontWeight = FontWeight.SemiBold
fontSize = 22.sp,
lineHeight = 24.sp,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(6.dp))
Spacer(modifier = Modifier.height(8.dp))
}
Text(
text = subtitle,
color = subtitleColor,
fontSize = 14.sp
fontSize = 15.sp,
lineHeight = 20.sp,
textAlign = TextAlign.Center
)
}
}
}
private fun CallHistoryRow.toCallHistoryItem(): CallHistoryItem {
val displayName = resolveDisplayName(peerTitle.orEmpty(), peerUsername.orEmpty(), peerKey)

View File

@@ -3653,6 +3653,7 @@ fun ProfilePhotoMenu(
expanded: Boolean,
onDismiss: () -> Unit,
isDarkTheme: Boolean,
onQrCodeClick: (() -> Unit)? = null,
onSetPhotoClick: () -> Unit,
onDeletePhotoClick: (() -> Unit)? = null,
hasAvatar: Boolean = false
@@ -3682,6 +3683,16 @@ fun ProfilePhotoMenu(
dismissOnClickOutside = true
)
) {
onQrCodeClick?.let { onQrClick ->
ProfilePhotoMenuItem(
icon = androidx.compose.ui.graphics.vector.rememberVectorPainter(TablerIcons.Scan),
text = "QR Code",
onClick = onQrClick,
tintColor = iconColor,
textColor = textColor
)
}
ProfilePhotoMenuItem(
icon = TelegramIcons.AddPhoto,
text = if (hasAvatar) "Set Profile Photo" else "Add Photo",

View File

@@ -67,6 +67,7 @@ import androidx.compose.ui.graphics.drawscope.rotate
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.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
@@ -187,6 +188,7 @@ private enum class RecordUiState {
@Composable
private fun RecordBlinkDot(
isDarkTheme: Boolean,
indicatorSize: Dp = 24.dp,
modifier: Modifier = Modifier
) {
var entered by remember { mutableStateOf(false) }
@@ -210,7 +212,7 @@ private fun RecordBlinkDot(
val dotColor = if (isDarkTheme) Color(0xFFFF5A5A) else Color(0xFFE84D4D)
Box(
modifier = modifier
.size(28.dp)
.size(indicatorSize)
.graphicsLayer {
scaleX = enterScale
scaleY = enterScale
@@ -231,6 +233,7 @@ private fun RecordBlinkDot(
private fun TelegramVoiceDeleteIndicator(
cancelProgress: Float,
isDarkTheme: Boolean,
indicatorSize: Dp = 24.dp,
modifier: Modifier = Modifier
) {
val progress = cancelProgress.coerceIn(0f, 1f)
@@ -284,12 +287,13 @@ private fun TelegramVoiceDeleteIndicator(
)
Box(
modifier = modifier.size(28.dp),
modifier = modifier.size(indicatorSize),
contentAlignment = Alignment.Center
) {
// Single recording dot (no duplicate red indicators).
RecordBlinkDot(
isDarkTheme = isDarkTheme,
indicatorSize = indicatorSize,
modifier = Modifier.graphicsLayer {
alpha = 1f - lottieAlpha
scaleX = 1f - 0.12f * lottieAlpha
@@ -402,16 +406,6 @@ private fun VoiceButtonBlob(
),
label = "voice_btn_blob_drift_x"
)
val driftY by transition.animateFloat(
initialValue = 1f,
targetValue = -1f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 1270, easing = LinearEasing),
repeatMode = RepeatMode.Reverse
),
label = "voice_btn_blob_drift_y"
)
val blobColor = if (isDarkTheme) Color(0xFF52C3FF) else Color(0xFF2D9CFF)
fun createBlobPath(
@@ -459,7 +453,7 @@ private fun VoiceButtonBlob(
Canvas(modifier = modifier) {
val center = Offset(
x = size.width * 0.5f + size.width * 0.05f * driftX,
y = size.height * 0.5f + size.height * 0.04f * driftY
y = size.height * 0.5f
)
val baseRadius = size.minDimension * 0.25f
@@ -1119,6 +1113,9 @@ fun MessageInputBar(
var isVoiceRecording by remember { mutableStateOf(false) }
var isVoiceRecordTransitioning by remember { mutableStateOf(false) }
var isVoiceCancelAnimating by remember { mutableStateOf(false) }
var holdCancelVisualUntilHidden by remember { mutableStateOf(false) }
var cancelProgressSeed by remember { mutableFloatStateOf(0f) }
var cancelFrozenElapsedMs by remember { mutableLongStateOf(0L) }
var keepMicGestureCapture by remember { mutableStateOf(false) }
var recordMode by rememberSaveable { mutableStateOf(RecordMode.VOICE) }
var recordUiState by remember { mutableStateOf(RecordUiState.IDLE) }
@@ -1209,6 +1206,8 @@ fun MessageInputBar(
isVoiceRecordTransitioning = false
if (!preserveCancelAnimation) {
isVoiceCancelAnimating = false
cancelFrozenElapsedMs = 0L
cancelProgressSeed = 0f
}
keepMicGestureCapture = false
if (INPUT_JUMP_LOG_ENABLED) inputJumpLog(
@@ -1292,6 +1291,11 @@ fun MessageInputBar(
fun startVoiceRecording() {
if (isVoiceRecording || isVoiceRecordTransitioning || voiceRecorder != null) return
// New recording session must never inherit stale cancel visuals.
isVoiceCancelAnimating = false
holdCancelVisualUntilHidden = false
cancelProgressSeed = 0f
cancelFrozenElapsedMs = 0L
if (INPUT_JUMP_LOG_ENABLED) inputJumpLog(
"startVoiceRecording begin mode=$recordMode state=$recordUiState kb=$isKeyboardVisible emojiBox=${coordinator.isEmojiBoxVisible} " +
"emojiPicker=$showEmojiPicker panelH=$inputPanelHeightPx normalH=$normalInputRowHeightPx"
@@ -1393,6 +1397,21 @@ fun MessageInputBar(
return
}
keepMicGestureCapture = false
// Freeze current swipe progress so cancel animation never "jumps back"
// after slideDx is reset by stopVoiceRecording().
val swipeSnapshot =
((-slideDx).coerceAtLeast(0f) / with(density) { 150.dp.toPx() })
.coerceIn(0f, 1f)
cancelProgressSeed = maxOf(cancelProgressSeed, swipeSnapshot)
cancelFrozenElapsedMs =
if (isVoicePaused && voicePausedElapsedMs > 0L) {
voicePausedElapsedMs
} else if (voiceRecordStartedAtMs > 0L) {
maxOf(voiceElapsedMs, System.currentTimeMillis() - voiceRecordStartedAtMs)
} else {
voiceElapsedMs
}
holdCancelVisualUntilHidden = true
isVoiceCancelAnimating = true
if (INPUT_JUMP_LOG_ENABLED) inputJumpLog("cancelVoiceRecordingWithAnimation start origin=$origin")
// Stop recorder immediately (off-main) to avoid stuck recording state / ANR on cancel.
@@ -1592,6 +1611,9 @@ fun MessageInputBar(
pendingRecordAfterPermission = false
isVoiceRecordTransitioning = false
isVoiceCancelAnimating = false
holdCancelVisualUntilHidden = false
cancelProgressSeed = 0f
cancelFrozenElapsedMs = 0L
keepMicGestureCapture = false
resetGestureState()
if (isVoiceRecording || voiceRecorder != null) {
@@ -2255,6 +2277,13 @@ fun MessageInputBar(
// True while visible OR while enter/exit animation is still running.
val isRecordingPanelComposed =
recordingPanelTransitionState.currentState || recordingPanelTransitionState.targetState
LaunchedEffect(isRecordingPanelComposed) {
if (!isRecordingPanelComposed) {
holdCancelVisualUntilHidden = false
cancelProgressSeed = 0f
cancelFrozenElapsedMs = 0L
}
}
androidx.compose.animation.AnimatedVisibility(
visibleState = recordingPanelTransitionState,
// Telegram-like smooth dissolve without any vertical resize.
@@ -2271,17 +2300,20 @@ fun MessageInputBar(
// Telegram-like proportions: large button that does not dominate the panel.
val recordingActionVisualScale = 1.42f // 40dp -> ~57dp visual size
val recordingActionInset = 34.dp
val recordingActionOverflowX = 8.dp
val recordingActionOverflowY = 10.dp
// Keep the scaled circle fully on-screen (no right-edge clipping).
val recordingActionOverflowX = 0.dp
val recordingActionOverflowY = 0.dp
val voiceLevel = remember(voiceWaves) { voiceWaves.lastOrNull() ?: 0f }
val cancelAnimProgress by animateFloatAsState(
targetValue = if (isVoiceCancelAnimating) 1f else 0f,
targetValue = if (isVoiceCancelAnimating || holdCancelVisualUntilHidden) 1f else 0f,
animationSpec = tween(durationMillis = 220, easing = FastOutSlowInEasing),
label = "voice_cancel_anim"
)
var recordUiEntered by remember { mutableStateOf(false) }
LaunchedEffect(isVoiceRecording, isVoiceCancelAnimating) {
if (isVoiceRecording || isVoiceCancelAnimating) {
val keepRecordUiVisible =
isVoiceRecording || isVoiceCancelAnimating || holdCancelVisualUntilHidden
LaunchedEffect(keepRecordUiVisible) {
if (keepRecordUiVisible) {
recordUiEntered = false
delay(16)
recordUiEntered = true
@@ -2386,9 +2418,16 @@ fun MessageInputBar(
val dragCancelProgress =
((-slideDx).coerceAtLeast(0f) / cancelDragThresholdPx)
.coerceIn(0f, 1f)
val seededCancelProgress =
if (isVoiceCancelAnimating || holdCancelVisualUntilHidden) {
cancelProgressSeed
} else {
0f
}
val leftDeleteProgress =
maxOf(
cancelAnimProgress,
seededCancelProgress,
FastOutSlowInEasing.transform(
(dragCancelProgress * 0.85f).coerceIn(0f, 1f)
)
@@ -2401,8 +2440,31 @@ fun MessageInputBar(
with(density) { (-58).dp.toPx() * collapseToTrash }
val collapseScale = 1f - 0.14f * collapseToTrash
val collapseAlpha = 1f - 0.55f * collapseToTrash
val timerToTrashShiftPx = collapseShiftPx * 0.35f
// Telegram-like timer -> trash flight.
// Start a little later than trash reveal, then accelerate into bin.
val timerFlyProgress =
FastOutLinearInEasing.transform(
((leftDeleteProgress - 0.1f) / 0.9f).coerceIn(0f, 1f)
)
// Stop motion at bin and fade out before any overshoot.
val timerReachBinProgress =
FastOutLinearInEasing.transform(
(timerFlyProgress / 0.78f).coerceIn(0f, 1f)
)
val timerToTrashShiftPx =
with(density) { (-46).dp.toPx() } * timerReachBinProgress
val timerToTrashScale = lerpFloat(1f, 0.52f, timerReachBinProgress)
val timerToTrashAlpha = 1f - timerReachBinProgress
val timerSpacerDp = lerpFloat(10f, 2f, collapseToTrash).dp
// Hard-hide trash right after cancel commit to prevent any one-frame reappear.
val hideIndicatorAfterCancelProgress =
if (holdCancelVisualUntilHidden && !isVoiceCancelAnimating) 1f else 0f
val timerDisplayMs =
if (isVoiceCancelAnimating || holdCancelVisualUntilHidden) {
if (cancelFrozenElapsedMs > 0L) cancelFrozenElapsedMs else voiceElapsedMs
} else {
voiceElapsedMs
}
Row(
modifier = Modifier
.fillMaxSize()
@@ -2417,7 +2479,7 @@ fun MessageInputBar(
modifier = Modifier
.size(40.dp)
.graphicsLayer {
alpha = recordUiAlpha
alpha = recordUiAlpha * (1f - hideIndicatorAfterCancelProgress)
translationX = with(density) { recordUiShift.toPx() }
},
contentAlignment = Alignment.Center
@@ -2425,26 +2487,26 @@ fun MessageInputBar(
TelegramVoiceDeleteIndicator(
cancelProgress = leftDeleteProgress,
isDarkTheme = isDarkTheme,
modifier = Modifier.size(28.dp)
indicatorSize = 24.dp
)
}
Spacer(modifier = Modifier.width(2.dp))
Text(
text = formatVoiceRecordTimer(voiceElapsedMs),
text = formatVoiceRecordTimer(timerDisplayMs),
color = recordingTextColor,
fontSize = 15.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.graphicsLayer {
alpha =
recordUiAlpha *
(1f - leftDeleteProgress * 0.22f) *
timerToTrashAlpha *
collapseAlpha
translationX =
with(density) { recordUiShift.toPx() } + timerToTrashShiftPx
scaleX = collapseScale
scaleY = collapseScale
scaleX = collapseScale * timerToTrashScale
scaleY = collapseScale * timerToTrashScale
}
)

View File

@@ -995,6 +995,7 @@ fun ProfileScreen(
hasAvatar = hasAvatar,
avatarRepository = avatarRepository,
backgroundBlurColorId = backgroundBlurColorId,
onQrCodeClick = onNavigateToMyQr,
onAvatarLongPress = {
if (hasAvatar) {
scope.launch {
@@ -1014,13 +1015,13 @@ fun ProfileScreen(
)
// ═══════════════════════════════════════════════════════════
// 📷 CAMERA BUTTON — at boundary between header and content
// 📷 + QR FLOATING BUTTONS — at boundary between header and content
// Positioned at bottom-right of header, half overlapping content area
// Fades out when collapsed or when avatar is expanded
// ═══════════════════════════════════════════════════════════
val cameraButtonSize = 60.dp
val cameraButtonAlpha = (1f - collapseProgress * 2f).coerceIn(0f, 1f)
if (cameraButtonAlpha > 0.01f) {
val floatingButtonsAlpha = (1f - collapseProgress * 2f).coerceIn(0f, 1f)
if (floatingButtonsAlpha > 0.01f) {
Box(
modifier = Modifier
.align(Alignment.TopEnd)
@@ -1028,15 +1029,21 @@ fun ProfileScreen(
x = (-16).dp,
y = headerHeight - cameraButtonSize / 2
)
.graphicsLayer { alpha = floatingButtonsAlpha }
) {
Box(
modifier = Modifier
.size(cameraButtonSize)
.graphicsLayer { alpha = cameraButtonAlpha }
.shadow(
elevation = 4.dp,
shape = CircleShape,
clip = false
)
.clip(CircleShape)
.background(if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFF0D8CF4))
.background(
if (isDarkTheme) Color(0xFF2A2A2A)
else Color(0xFF0D8CF4)
)
.clickable { showPhotoPicker = true },
contentAlignment = Alignment.Center
) {
@@ -1049,6 +1056,7 @@ fun ProfileScreen(
}
}
}
}
// 🖼️ Кастомный быстрый Photo Picker
ProfilePhotoPicker(
@@ -1103,6 +1111,7 @@ private fun CollapsingProfileHeader(
hasAvatar: Boolean,
avatarRepository: AvatarRepository?,
backgroundBlurColorId: String = "avatar",
onQrCodeClick: () -> Unit = {},
onAvatarLongPress: () -> Unit = {}
) {
@Suppress("UNUSED_VARIABLE")
@@ -1379,6 +1388,10 @@ private fun CollapsingProfileHeader(
expanded = showAvatarMenu,
onDismiss = { onAvatarMenuChange(false) },
isDarkTheme = isDarkTheme,
onQrCodeClick = {
onAvatarMenuChange(false)
onQrCodeClick()
},
onSetPhotoClick = {
onAvatarMenuChange(false)
onSetPhotoClick()

File diff suppressed because one or more lines are too long