Доработан UI чатов и звонков (запись ГС, экран звонков, профиль)
This commit is contained in:
@@ -1382,6 +1382,9 @@ fun ChatsListScreen(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Keep distance from footer divider so it never overlays Settings.
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
1
app/src/main/res/raw/phone_duck.json
Normal file
1
app/src/main/res/raw/phone_duck.json
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user