Доработан 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.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row 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.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items 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.Icons
import androidx.compose.material.icons.filled.Call import androidx.compose.material.icons.filled.Call
import androidx.compose.material.icons.filled.CallMade 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.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight 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.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 androidx.compose.ui.platform.LocalContext 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.CallHistoryRow
import com.rosetta.messenger.database.RosettaDatabase import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.network.SearchUser import com.rosetta.messenger.network.SearchUser
@@ -106,17 +111,22 @@ fun CallsHistoryScreen(
LazyColumn( LazyColumn(
modifier = modifier.fillMaxSize().background(backgroundColor), 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()) { if (items.isEmpty()) {
item(key = "empty_calls") { item(key = "empty_calls") {
Column(
modifier = Modifier.fillParentMaxSize(),
verticalArrangement = Arrangement.Center
) {
EmptyCallsState( EmptyCallsState(
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
title = "No calls yet", title = "No Calls Yet",
subtitle = "Your call history will appear here", subtitle = "Your recent voice and video calls will\nappear here.",
modifier = Modifier.fillMaxWidth().padding(top = 64.dp) modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp)
) )
} }
}
} else { } else {
items(items, key = { it.messageId }) { item -> items(items, key = { it.messageId }) { item ->
CallHistoryRowItem( CallHistoryRowItem(
@@ -273,41 +283,65 @@ private fun EmptyCallsState(
subtitle: String, subtitle: String,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val iconTint = if (isDarkTheme) Color(0xFF5B5C63) else Color(0xFFAFB0B8) val titleColor = if (isDarkTheme) Color(0xFFEDEDF2) else Color(0xFF1C1C1E)
val titleColor = if (isDarkTheme) Color(0xFFE1E1E6) else Color(0xFF1F1F23) val subtitleColor = if (isDarkTheme) Color(0xFF9D9DA3) else Color(0xFF8E8E93)
val subtitleColor = if (isDarkTheme) Color(0xFF9D9DA3) else Color(0xFF7A7A80) 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( Column(
modifier = modifier.padding(horizontal = 32.dp), modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center verticalArrangement = Arrangement.Center
) { ) {
Box( Column(
modifier = Modifier.size(72.dp).background(iconTint.copy(alpha = 0.2f), CircleShape), modifier = Modifier
contentAlignment = Alignment.Center .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( Icon(
imageVector = Icons.Default.Call, imageVector = Icons.Default.Call,
contentDescription = null, contentDescription = null,
tint = iconTint, tint = subtitleColor,
modifier = Modifier.size(34.dp) modifier = Modifier.size(52.dp)
) )
} }
Spacer(modifier = Modifier.height(14.dp)) Spacer(modifier = Modifier.height(18.dp))
if (title.isNotBlank()) {
Text( Text(
text = title, text = title,
color = titleColor, color = titleColor,
fontSize = 18.sp, fontSize = 22.sp,
fontWeight = FontWeight.SemiBold lineHeight = 24.sp,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Center
) )
Spacer(modifier = Modifier.height(6.dp)) Spacer(modifier = Modifier.height(8.dp))
}
Text( Text(
text = subtitle, text = subtitle,
color = subtitleColor, color = subtitleColor,
fontSize = 14.sp fontSize = 15.sp,
lineHeight = 20.sp,
textAlign = TextAlign.Center
) )
} }
} }
}
private fun CallHistoryRow.toCallHistoryItem(): CallHistoryItem { private fun CallHistoryRow.toCallHistoryItem(): CallHistoryItem {
val displayName = resolveDisplayName(peerTitle.orEmpty(), peerUsername.orEmpty(), peerKey) val displayName = resolveDisplayName(peerTitle.orEmpty(), peerUsername.orEmpty(), peerKey)

View File

@@ -3653,6 +3653,7 @@ fun ProfilePhotoMenu(
expanded: Boolean, expanded: Boolean,
onDismiss: () -> Unit, onDismiss: () -> Unit,
isDarkTheme: Boolean, isDarkTheme: Boolean,
onQrCodeClick: (() -> Unit)? = null,
onSetPhotoClick: () -> Unit, onSetPhotoClick: () -> Unit,
onDeletePhotoClick: (() -> Unit)? = null, onDeletePhotoClick: (() -> Unit)? = null,
hasAvatar: Boolean = false hasAvatar: Boolean = false
@@ -3682,6 +3683,16 @@ fun ProfilePhotoMenu(
dismissOnClickOutside = true 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( ProfilePhotoMenuItem(
icon = TelegramIcons.AddPhoto, icon = TelegramIcons.AddPhoto,
text = if (hasAvatar) "Set Profile Photo" else "Add Photo", 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.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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
@@ -187,6 +188,7 @@ private enum class RecordUiState {
@Composable @Composable
private fun RecordBlinkDot( private fun RecordBlinkDot(
isDarkTheme: Boolean, isDarkTheme: Boolean,
indicatorSize: Dp = 24.dp,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
var entered by remember { mutableStateOf(false) } var entered by remember { mutableStateOf(false) }
@@ -210,7 +212,7 @@ private fun RecordBlinkDot(
val dotColor = if (isDarkTheme) Color(0xFFFF5A5A) else Color(0xFFE84D4D) val dotColor = if (isDarkTheme) Color(0xFFFF5A5A) else Color(0xFFE84D4D)
Box( Box(
modifier = modifier modifier = modifier
.size(28.dp) .size(indicatorSize)
.graphicsLayer { .graphicsLayer {
scaleX = enterScale scaleX = enterScale
scaleY = enterScale scaleY = enterScale
@@ -231,6 +233,7 @@ private fun RecordBlinkDot(
private fun TelegramVoiceDeleteIndicator( private fun TelegramVoiceDeleteIndicator(
cancelProgress: Float, cancelProgress: Float,
isDarkTheme: Boolean, isDarkTheme: Boolean,
indicatorSize: Dp = 24.dp,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val progress = cancelProgress.coerceIn(0f, 1f) val progress = cancelProgress.coerceIn(0f, 1f)
@@ -284,12 +287,13 @@ private fun TelegramVoiceDeleteIndicator(
) )
Box( Box(
modifier = modifier.size(28.dp), modifier = modifier.size(indicatorSize),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
// Single recording dot (no duplicate red indicators). // Single recording dot (no duplicate red indicators).
RecordBlinkDot( RecordBlinkDot(
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
indicatorSize = indicatorSize,
modifier = Modifier.graphicsLayer { modifier = Modifier.graphicsLayer {
alpha = 1f - lottieAlpha alpha = 1f - lottieAlpha
scaleX = 1f - 0.12f * lottieAlpha scaleX = 1f - 0.12f * lottieAlpha
@@ -402,16 +406,6 @@ private fun VoiceButtonBlob(
), ),
label = "voice_btn_blob_drift_x" 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) val blobColor = if (isDarkTheme) Color(0xFF52C3FF) else Color(0xFF2D9CFF)
fun createBlobPath( fun createBlobPath(
@@ -459,7 +453,7 @@ private fun VoiceButtonBlob(
Canvas(modifier = modifier) { Canvas(modifier = modifier) {
val center = Offset( val center = Offset(
x = size.width * 0.5f + size.width * 0.05f * driftX, 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 val baseRadius = size.minDimension * 0.25f
@@ -1119,6 +1113,9 @@ fun MessageInputBar(
var isVoiceRecording by remember { mutableStateOf(false) } var isVoiceRecording by remember { mutableStateOf(false) }
var isVoiceRecordTransitioning by remember { mutableStateOf(false) } var isVoiceRecordTransitioning by remember { mutableStateOf(false) }
var isVoiceCancelAnimating 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 keepMicGestureCapture by remember { mutableStateOf(false) }
var recordMode by rememberSaveable { mutableStateOf(RecordMode.VOICE) } var recordMode by rememberSaveable { mutableStateOf(RecordMode.VOICE) }
var recordUiState by remember { mutableStateOf(RecordUiState.IDLE) } var recordUiState by remember { mutableStateOf(RecordUiState.IDLE) }
@@ -1209,6 +1206,8 @@ fun MessageInputBar(
isVoiceRecordTransitioning = false isVoiceRecordTransitioning = false
if (!preserveCancelAnimation) { if (!preserveCancelAnimation) {
isVoiceCancelAnimating = false isVoiceCancelAnimating = false
cancelFrozenElapsedMs = 0L
cancelProgressSeed = 0f
} }
keepMicGestureCapture = false keepMicGestureCapture = false
if (INPUT_JUMP_LOG_ENABLED) inputJumpLog( if (INPUT_JUMP_LOG_ENABLED) inputJumpLog(
@@ -1292,6 +1291,11 @@ fun MessageInputBar(
fun startVoiceRecording() { fun startVoiceRecording() {
if (isVoiceRecording || isVoiceRecordTransitioning || voiceRecorder != null) return 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( if (INPUT_JUMP_LOG_ENABLED) inputJumpLog(
"startVoiceRecording begin mode=$recordMode state=$recordUiState kb=$isKeyboardVisible emojiBox=${coordinator.isEmojiBoxVisible} " + "startVoiceRecording begin mode=$recordMode state=$recordUiState kb=$isKeyboardVisible emojiBox=${coordinator.isEmojiBoxVisible} " +
"emojiPicker=$showEmojiPicker panelH=$inputPanelHeightPx normalH=$normalInputRowHeightPx" "emojiPicker=$showEmojiPicker panelH=$inputPanelHeightPx normalH=$normalInputRowHeightPx"
@@ -1393,6 +1397,21 @@ fun MessageInputBar(
return return
} }
keepMicGestureCapture = false 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 isVoiceCancelAnimating = true
if (INPUT_JUMP_LOG_ENABLED) inputJumpLog("cancelVoiceRecordingWithAnimation start origin=$origin") if (INPUT_JUMP_LOG_ENABLED) inputJumpLog("cancelVoiceRecordingWithAnimation start origin=$origin")
// Stop recorder immediately (off-main) to avoid stuck recording state / ANR on cancel. // Stop recorder immediately (off-main) to avoid stuck recording state / ANR on cancel.
@@ -1592,6 +1611,9 @@ fun MessageInputBar(
pendingRecordAfterPermission = false pendingRecordAfterPermission = false
isVoiceRecordTransitioning = false isVoiceRecordTransitioning = false
isVoiceCancelAnimating = false isVoiceCancelAnimating = false
holdCancelVisualUntilHidden = false
cancelProgressSeed = 0f
cancelFrozenElapsedMs = 0L
keepMicGestureCapture = false keepMicGestureCapture = false
resetGestureState() resetGestureState()
if (isVoiceRecording || voiceRecorder != null) { if (isVoiceRecording || voiceRecorder != null) {
@@ -2255,6 +2277,13 @@ fun MessageInputBar(
// True while visible OR while enter/exit animation is still running. // True while visible OR while enter/exit animation is still running.
val isRecordingPanelComposed = val isRecordingPanelComposed =
recordingPanelTransitionState.currentState || recordingPanelTransitionState.targetState recordingPanelTransitionState.currentState || recordingPanelTransitionState.targetState
LaunchedEffect(isRecordingPanelComposed) {
if (!isRecordingPanelComposed) {
holdCancelVisualUntilHidden = false
cancelProgressSeed = 0f
cancelFrozenElapsedMs = 0L
}
}
androidx.compose.animation.AnimatedVisibility( androidx.compose.animation.AnimatedVisibility(
visibleState = recordingPanelTransitionState, visibleState = recordingPanelTransitionState,
// Telegram-like smooth dissolve without any vertical resize. // 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. // Telegram-like proportions: large button that does not dominate the panel.
val recordingActionVisualScale = 1.42f // 40dp -> ~57dp visual size val recordingActionVisualScale = 1.42f // 40dp -> ~57dp visual size
val recordingActionInset = 34.dp val recordingActionInset = 34.dp
val recordingActionOverflowX = 8.dp // Keep the scaled circle fully on-screen (no right-edge clipping).
val recordingActionOverflowY = 10.dp val recordingActionOverflowX = 0.dp
val recordingActionOverflowY = 0.dp
val voiceLevel = remember(voiceWaves) { voiceWaves.lastOrNull() ?: 0f } val voiceLevel = remember(voiceWaves) { voiceWaves.lastOrNull() ?: 0f }
val cancelAnimProgress by animateFloatAsState( val cancelAnimProgress by animateFloatAsState(
targetValue = if (isVoiceCancelAnimating) 1f else 0f, targetValue = if (isVoiceCancelAnimating || holdCancelVisualUntilHidden) 1f else 0f,
animationSpec = tween(durationMillis = 220, easing = FastOutSlowInEasing), animationSpec = tween(durationMillis = 220, easing = FastOutSlowInEasing),
label = "voice_cancel_anim" label = "voice_cancel_anim"
) )
var recordUiEntered by remember { mutableStateOf(false) } var recordUiEntered by remember { mutableStateOf(false) }
LaunchedEffect(isVoiceRecording, isVoiceCancelAnimating) { val keepRecordUiVisible =
if (isVoiceRecording || isVoiceCancelAnimating) { isVoiceRecording || isVoiceCancelAnimating || holdCancelVisualUntilHidden
LaunchedEffect(keepRecordUiVisible) {
if (keepRecordUiVisible) {
recordUiEntered = false recordUiEntered = false
delay(16) delay(16)
recordUiEntered = true recordUiEntered = true
@@ -2386,9 +2418,16 @@ fun MessageInputBar(
val dragCancelProgress = val dragCancelProgress =
((-slideDx).coerceAtLeast(0f) / cancelDragThresholdPx) ((-slideDx).coerceAtLeast(0f) / cancelDragThresholdPx)
.coerceIn(0f, 1f) .coerceIn(0f, 1f)
val seededCancelProgress =
if (isVoiceCancelAnimating || holdCancelVisualUntilHidden) {
cancelProgressSeed
} else {
0f
}
val leftDeleteProgress = val leftDeleteProgress =
maxOf( maxOf(
cancelAnimProgress, cancelAnimProgress,
seededCancelProgress,
FastOutSlowInEasing.transform( FastOutSlowInEasing.transform(
(dragCancelProgress * 0.85f).coerceIn(0f, 1f) (dragCancelProgress * 0.85f).coerceIn(0f, 1f)
) )
@@ -2401,8 +2440,31 @@ fun MessageInputBar(
with(density) { (-58).dp.toPx() * collapseToTrash } with(density) { (-58).dp.toPx() * collapseToTrash }
val collapseScale = 1f - 0.14f * collapseToTrash val collapseScale = 1f - 0.14f * collapseToTrash
val collapseAlpha = 1f - 0.55f * 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 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( Row(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -2417,7 +2479,7 @@ fun MessageInputBar(
modifier = Modifier modifier = Modifier
.size(40.dp) .size(40.dp)
.graphicsLayer { .graphicsLayer {
alpha = recordUiAlpha alpha = recordUiAlpha * (1f - hideIndicatorAfterCancelProgress)
translationX = with(density) { recordUiShift.toPx() } translationX = with(density) { recordUiShift.toPx() }
}, },
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
@@ -2425,26 +2487,26 @@ fun MessageInputBar(
TelegramVoiceDeleteIndicator( TelegramVoiceDeleteIndicator(
cancelProgress = leftDeleteProgress, cancelProgress = leftDeleteProgress,
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
modifier = Modifier.size(28.dp) indicatorSize = 24.dp
) )
} }
Spacer(modifier = Modifier.width(2.dp)) Spacer(modifier = Modifier.width(2.dp))
Text( Text(
text = formatVoiceRecordTimer(voiceElapsedMs), text = formatVoiceRecordTimer(timerDisplayMs),
color = recordingTextColor, color = recordingTextColor,
fontSize = 15.sp, fontSize = 15.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
modifier = Modifier.graphicsLayer { modifier = Modifier.graphicsLayer {
alpha = alpha =
recordUiAlpha * recordUiAlpha *
(1f - leftDeleteProgress * 0.22f) * timerToTrashAlpha *
collapseAlpha collapseAlpha
translationX = translationX =
with(density) { recordUiShift.toPx() } + timerToTrashShiftPx with(density) { recordUiShift.toPx() } + timerToTrashShiftPx
scaleX = collapseScale scaleX = collapseScale * timerToTrashScale
scaleY = collapseScale scaleY = collapseScale * timerToTrashScale
} }
) )

View File

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

File diff suppressed because one or more lines are too long