Доработан 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.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,16 +111,21 @@ 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") {
|
||||
EmptyCallsState(
|
||||
isDarkTheme = isDarkTheme,
|
||||
title = "No calls yet",
|
||||
subtitle = "Your call history will appear here",
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 64.dp)
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier.fillParentMaxSize(),
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
EmptyCallsState(
|
||||
isDarkTheme = isDarkTheme,
|
||||
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 ->
|
||||
@@ -273,39 +283,63 @@ 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
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Call,
|
||||
contentDescription = null,
|
||||
tint = iconTint,
|
||||
modifier = Modifier.size(34.dp)
|
||||
if (lottieComposition != null) {
|
||||
LottieAnimation(
|
||||
composition = lottieComposition,
|
||||
progress = { lottieProgress },
|
||||
modifier = Modifier.size(184.dp)
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Call,
|
||||
contentDescription = null,
|
||||
tint = subtitleColor,
|
||||
modifier = Modifier.size(52.dp)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(18.dp))
|
||||
if (title.isNotBlank()) {
|
||||
Text(
|
||||
text = title,
|
||||
color = titleColor,
|
||||
fontSize = 22.sp,
|
||||
lineHeight = 24.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
Text(
|
||||
text = subtitle,
|
||||
color = subtitleColor,
|
||||
fontSize = 15.sp,
|
||||
lineHeight = 20.sp,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(14.dp))
|
||||
Text(
|
||||
text = title,
|
||||
color = titleColor,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Text(
|
||||
text = subtitle,
|
||||
color = subtitleColor,
|
||||
fontSize = 14.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
@@ -2253,8 +2275,15 @@ fun MessageInputBar(
|
||||
targetState = isRecordingPanelVisible
|
||||
}
|
||||
// True while visible OR while enter/exit animation is still running.
|
||||
val isRecordingPanelComposed =
|
||||
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
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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,24 +1029,31 @@ fun ProfileScreen(
|
||||
x = (-16).dp,
|
||||
y = headerHeight - cameraButtonSize / 2
|
||||
)
|
||||
.size(cameraButtonSize)
|
||||
.graphicsLayer { alpha = cameraButtonAlpha }
|
||||
.shadow(
|
||||
elevation = 4.dp,
|
||||
shape = CircleShape,
|
||||
clip = false
|
||||
)
|
||||
.clip(CircleShape)
|
||||
.background(if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFF0D8CF4))
|
||||
.clickable { showPhotoPicker = true },
|
||||
contentAlignment = Alignment.Center
|
||||
.graphicsLayer { alpha = floatingButtonsAlpha }
|
||||
) {
|
||||
Icon(
|
||||
painter = TelegramIcons.AddPhoto,
|
||||
contentDescription = "Change avatar",
|
||||
tint = if (isDarkTheme) Color(0xFF8E8E93) else Color.White,
|
||||
modifier = Modifier.size(26.dp).offset(x = 2.dp)
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(cameraButtonSize)
|
||||
.shadow(
|
||||
elevation = 4.dp,
|
||||
shape = CircleShape,
|
||||
clip = false
|
||||
)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
if (isDarkTheme) Color(0xFF2A2A2A)
|
||||
else Color(0xFF0D8CF4)
|
||||
)
|
||||
.clickable { showPhotoPicker = true },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
painter = TelegramIcons.AddPhoto,
|
||||
contentDescription = "Change avatar",
|
||||
tint = if (isDarkTheme) Color(0xFF8E8E93) else Color.White,
|
||||
modifier = Modifier.size(26.dp).offset(x = 2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
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