feat: enhance RequestsSection layout and add full-screen avatar viewer in ProfileScreen
This commit is contained in:
@@ -627,6 +627,7 @@ fun ChatsListScreen(
|
||||
iconColor = menuIconColor,
|
||||
textColor = textColor,
|
||||
badge = if (topLevelRequestsCount > 0) topLevelRequestsCount.toString() else null,
|
||||
badgeColor = accentColor,
|
||||
onClick = {
|
||||
scope.launch {
|
||||
drawerState.close()
|
||||
@@ -913,7 +914,7 @@ fun ChatsListScreen(
|
||||
.offset(x = 2.dp, y = (-2).dp)
|
||||
.size(8.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color(0xFFE53935))
|
||||
.background(if (isDarkTheme) PrimaryBlueDark else PrimaryBlue)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2870,7 +2871,7 @@ fun TypingIndicatorSmall() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 📬 Секция Requests — Telegram-style chat item (как Archived Chats) */
|
||||
/** 📬 Секция Requests — Telegram Archived Chats style */
|
||||
@Composable
|
||||
fun RequestsSection(
|
||||
count: Int,
|
||||
@@ -2883,6 +2884,7 @@ fun RequestsSection(
|
||||
remember(isDarkTheme) { if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) }
|
||||
val iconBgColor =
|
||||
remember(isDarkTheme) { if (isDarkTheme) Color(0xFF3A3A3C) else Color(0xFFC7C7CC) }
|
||||
val accentColor = if (isDarkTheme) PrimaryBlueDark else PrimaryBlue
|
||||
|
||||
// Последний запрос — показываем имя отправителя как subtitle
|
||||
val lastRequest = remember(requests) { requests.firstOrNull() }
|
||||
@@ -2899,74 +2901,58 @@ fun RequestsSection(
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier =
|
||||
Modifier.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Иконка — круглый аватар как в Telegram Archived Chats
|
||||
Box(
|
||||
Column {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier.size(56.dp)
|
||||
.clip(CircleShape)
|
||||
.background(iconBgColor),
|
||||
contentAlignment = Alignment.Center
|
||||
Modifier.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = TablerIcons.MailForward,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(26.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
// Текст: название + последний отправитель
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
// Иконка — круглый аватар как Archived Chats в Telegram
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.size(56.dp)
|
||||
.clip(CircleShape)
|
||||
.background(iconBgColor),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "Requests",
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 16.sp,
|
||||
color = textColor,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f)
|
||||
Icon(
|
||||
imageVector = TablerIcons.MailForward,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(26.dp)
|
||||
)
|
||||
}
|
||||
|
||||
if (subtitle.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
// Верхняя строка: название + badge
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = subtitle,
|
||||
fontSize = 14.sp,
|
||||
color = secondaryTextColor,
|
||||
text = "Requests",
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 16.sp,
|
||||
color = textColor,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
// Badge с количеством
|
||||
// Badge справа на уровне заголовка
|
||||
if (count > 0) {
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.defaultMinSize(minWidth = 22.dp, minHeight = 22.dp)
|
||||
.clip(RoundedCornerShape(11.dp))
|
||||
.background(Color(0xFFE53935)),
|
||||
.clip(CircleShape)
|
||||
.background(accentColor),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
@@ -2974,37 +2960,33 @@ fun RequestsSection(
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White,
|
||||
modifier = Modifier.padding(horizontal = 5.dp, vertical = 1.dp)
|
||||
lineHeight = 12.sp,
|
||||
modifier = Modifier.padding(horizontal = 5.dp, vertical = 3.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (count > 0) {
|
||||
// Если нет subtitle но есть count
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.defaultMinSize(minWidth = 22.dp, minHeight = 22.dp)
|
||||
.clip(RoundedCornerShape(11.dp))
|
||||
.background(Color(0xFFE53935)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = if (count > 99) "99+" else count.toString(),
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White,
|
||||
modifier = Modifier.padding(horizontal = 5.dp, vertical = 1.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// Нижняя строка: subtitle (последний запрос)
|
||||
if (subtitle.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = subtitle,
|
||||
fontSize = 14.sp,
|
||||
color = secondaryTextColor,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Разделитель как у обычных чатов
|
||||
Divider(
|
||||
color = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8),
|
||||
thickness = 0.5.dp,
|
||||
modifier = Modifier.padding(start = 84.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3208,6 +3190,7 @@ fun DrawerMenuItemEnhanced(
|
||||
iconColor: Color,
|
||||
textColor: Color,
|
||||
badge: String? = null,
|
||||
badgeColor: Color = Color(0xFFE53935),
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
@@ -3237,17 +3220,21 @@ fun DrawerMenuItemEnhanced(
|
||||
badge?.let {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.background(
|
||||
color = Color(0xFFE53935),
|
||||
shape = RoundedCornerShape(10.dp)
|
||||
)
|
||||
.padding(horizontal = 8.dp, vertical = 2.dp)
|
||||
Modifier
|
||||
.defaultMinSize(minWidth = 22.dp, minHeight = 22.dp)
|
||||
.background(
|
||||
color = badgeColor,
|
||||
shape = CircleShape
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = it,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = Color.White
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White,
|
||||
lineHeight = 12.sp,
|
||||
modifier = Modifier.padding(horizontal = 5.dp, vertical = 3.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ fun RequestsListScreen(
|
||||
val blockedUsers by chatsViewModel.blockedUsers.collectAsState()
|
||||
val scope = rememberCoroutineScope()
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7)
|
||||
val headerColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFF0D8CF4)
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
|
||||
Scaffold(
|
||||
@@ -61,15 +62,16 @@ fun RequestsListScreen(
|
||||
text = "Requests",
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 20.sp,
|
||||
color = textColor
|
||||
color = Color.White
|
||||
)
|
||||
},
|
||||
colors =
|
||||
TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = backgroundColor,
|
||||
scrolledContainerColor = backgroundColor,
|
||||
navigationIconContentColor = textColor,
|
||||
titleContentColor = textColor
|
||||
containerColor = headerColor,
|
||||
scrolledContainerColor = headerColor,
|
||||
navigationIconContentColor = Color.White,
|
||||
titleContentColor = Color.White,
|
||||
actionIconContentColor = Color.White
|
||||
)
|
||||
)
|
||||
},
|
||||
|
||||
@@ -77,20 +77,28 @@ fun BoxScope.BlurredAvatarBackground(
|
||||
val avatars by avatarRepository?.getAvatars(publicKey, allDecode = false)?.collectAsState()
|
||||
?: remember { mutableStateOf(emptyList()) }
|
||||
|
||||
var originalBitmap by remember(avatars) { mutableStateOf<Bitmap?>(null) }
|
||||
var blurredBitmap by remember(avatars) { mutableStateOf<Bitmap?>(null) }
|
||||
// Stable key based on content, not list reference — prevents bitmap reset during recomposition
|
||||
val avatarKey = remember(avatars) {
|
||||
avatars.firstOrNull()?.timestamp ?: 0L
|
||||
}
|
||||
|
||||
LaunchedEffect(avatars) {
|
||||
if (avatars.isNotEmpty()) {
|
||||
originalBitmap = withContext(Dispatchers.IO) {
|
||||
AvatarFileManager.base64ToBitmap(avatars.first().base64Data)
|
||||
// Don't reset bitmap to null when key changes — keep showing old blur until new one is ready
|
||||
var originalBitmap by remember { mutableStateOf<Bitmap?>(null) }
|
||||
var blurredBitmap by remember { mutableStateOf<Bitmap?>(null) }
|
||||
|
||||
LaunchedEffect(avatarKey) {
|
||||
val currentAvatars = avatars
|
||||
if (currentAvatars.isNotEmpty()) {
|
||||
val newOriginal = withContext(Dispatchers.IO) {
|
||||
AvatarFileManager.base64ToBitmap(currentAvatars.first().base64Data)
|
||||
}
|
||||
originalBitmap?.let { bitmap ->
|
||||
if (newOriginal != null) {
|
||||
originalBitmap = newOriginal
|
||||
blurredBitmap = withContext(Dispatchers.Default) {
|
||||
val scaledBitmap = Bitmap.createScaledBitmap(
|
||||
bitmap,
|
||||
bitmap.width / 4,
|
||||
bitmap.height / 4,
|
||||
newOriginal,
|
||||
newOriginal.width / 4,
|
||||
newOriginal.height / 4,
|
||||
true
|
||||
)
|
||||
var result = scaledBitmap
|
||||
|
||||
@@ -41,6 +41,7 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
@@ -368,17 +369,15 @@ fun OtherProfileScreen(
|
||||
else -> 0f
|
||||
}
|
||||
|
||||
// 🔥 Плавная spring анимация для snap
|
||||
// Плавная spring анимация для snap (без bounce для гладкости)
|
||||
val animatedOverscroll by animateFloatAsState(
|
||||
targetValue = targetOverscroll,
|
||||
animationSpec = if (isDragging && !isPulledDown) {
|
||||
// Без анимации во время drag (до snap)
|
||||
spring(stiffness = Spring.StiffnessHigh)
|
||||
} else {
|
||||
// Плавная анимация для snap
|
||||
spring(
|
||||
dampingRatio = Spring.DampingRatioLowBouncy, // Лёгкий bounce
|
||||
stiffness = Spring.StiffnessMediumLow // Плавное движение
|
||||
dampingRatio = Spring.DampingRatioNoBouncy,
|
||||
stiffness = Spring.StiffnessMedium
|
||||
)
|
||||
},
|
||||
label = "overscroll"
|
||||
@@ -1674,47 +1673,118 @@ private fun CollapsingOtherProfileHeader(
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
val textColor = Color.White
|
||||
|
||||
Box(modifier = Modifier.fillMaxWidth().height(headerHeight)) {
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 🎨 BLURRED AVATAR BACKGROUND
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
BlurredAvatarBackground(
|
||||
publicKey = publicKey,
|
||||
avatarRepository = avatarRepository,
|
||||
fallbackColor = avatarColors.backgroundColor,
|
||||
blurRadius = 20f,
|
||||
alpha = 0.9f,
|
||||
overlayColors = com.rosetta.messenger.ui.settings.BackgroundBlurPresets.getOverlayColors(backgroundBlurColorId),
|
||||
isDarkTheme = isDarkTheme
|
||||
)
|
||||
Box(modifier = Modifier.fillMaxWidth().height(headerHeight).clipToBounds()) {
|
||||
// Expansion fraction — computed early so blur can fade during expansion
|
||||
val expandFractionEarly = expansionProgress.coerceIn(0f, 1f)
|
||||
val blurAlpha = (1f - expandFractionEarly * 2.5f).coerceIn(0f, 1f)
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 👤 AVATAR with METABALL EFFECT - Liquid merge animation
|
||||
// При скролле вверх аватарка "сливается" с Dynamic Island
|
||||
// При свайпе вниз - расширяется на весь экран
|
||||
// 🎨 BLURRED AVATAR BACKGROUND - fades out during expansion
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
ProfileMetaballEffect(
|
||||
collapseProgress = collapseProgress,
|
||||
expansionProgress = expansionProgress,
|
||||
statusBarHeight = statusBarHeight,
|
||||
headerHeight = headerHeight,
|
||||
hasAvatar = hasAvatar,
|
||||
avatarColor = avatarColors.backgroundColor,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
// Содержимое аватара
|
||||
if (hasAvatar && avatarRepository != null) {
|
||||
OtherProfileFullSizeAvatar(
|
||||
if (blurAlpha > 0.01f) {
|
||||
Box(modifier = Modifier.matchParentSize().graphicsLayer { alpha = blurAlpha }) {
|
||||
BlurredAvatarBackground(
|
||||
publicKey = publicKey,
|
||||
avatarRepository = avatarRepository,
|
||||
fallbackColor = avatarColors.backgroundColor,
|
||||
blurRadius = 20f,
|
||||
alpha = 0.9f,
|
||||
overlayColors = com.rosetta.messenger.ui.settings.BackgroundBlurPresets.getOverlayColors(backgroundBlurColorId),
|
||||
isDarkTheme = isDarkTheme
|
||||
)
|
||||
} else {
|
||||
// Placeholder без аватарки
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().background(avatarColors.backgroundColor),
|
||||
contentAlignment = Alignment.Center
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 👤 AVATAR — Telegram-style expansion on pull-down
|
||||
// При скролле вверх: metaball merge с Dynamic Island
|
||||
// При свайпе вниз: аватарка раскрывается на весь блок (circle → rect)
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
val avatarSize = androidx.compose.ui.unit.lerp(
|
||||
AVATAR_SIZE_EXPANDED_OTHER, AVATAR_SIZE_COLLAPSED_OTHER, collapseProgress
|
||||
)
|
||||
val avatarAlpha = (1f - collapseProgress * 1.8f).coerceIn(0f, 1f)
|
||||
val contentAreaHeight = EXPANDED_HEADER_HEIGHT_OTHER - 70.dp
|
||||
val avatarExpandedY = statusBarHeight + (contentAreaHeight - AVATAR_SIZE_EXPANDED_OTHER) / 2
|
||||
val avatarCollapsedY = statusBarHeight + (COLLAPSED_HEADER_HEIGHT_OTHER - AVATAR_SIZE_COLLAPSED_OTHER) / 2
|
||||
val avatarY = androidx.compose.ui.unit.lerp(avatarExpandedY, avatarCollapsedY, collapseProgress)
|
||||
|
||||
// Telegram-style expansion: circle → full header rect
|
||||
val screenWidth = LocalConfiguration.current.screenWidthDp.dp
|
||||
val expandFraction = expansionProgress.coerceIn(0f, 1f)
|
||||
val expandedAvatarWidth = androidx.compose.ui.unit.lerp(avatarSize, screenWidth, expandFraction)
|
||||
val expandedAvatarHeight = androidx.compose.ui.unit.lerp(avatarSize, headerHeight, expandFraction)
|
||||
val cornerRadius = androidx.compose.ui.unit.lerp(avatarSize / 2, 0.dp, expandFraction)
|
||||
val avatarCenterX = (screenWidth - avatarSize) / 2
|
||||
val expandedAvatarX = androidx.compose.ui.unit.lerp(avatarCenterX, 0.dp, expandFraction)
|
||||
val expandedAvatarY = androidx.compose.ui.unit.lerp(avatarY, 0.dp, expandFraction)
|
||||
|
||||
// Pre-compute pixel values for graphicsLayer (avoids layout-phase recomposition)
|
||||
val expandedAvatarXPx = with(density) { expandedAvatarX.toPx() }
|
||||
val expandedAvatarYPx = with(density) { expandedAvatarY.toPx() }
|
||||
|
||||
// Metaball alpha: visible only when NOT expanding (normal collapse animation)
|
||||
val metaballAlpha = (1f - expandFraction * 10f).coerceIn(0f, 1f)
|
||||
// Expansion avatar alpha: visible when expanding
|
||||
val expansionAvatarAlpha = (expandFraction * 10f).coerceIn(0f, 1f)
|
||||
|
||||
// Layer 1: Metaball effect for normal collapse (fades out when expanding)
|
||||
if (metaballAlpha > 0.01f) {
|
||||
Box(modifier = Modifier.fillMaxSize().graphicsLayer { alpha = metaballAlpha }) {
|
||||
ProfileMetaballEffect(
|
||||
collapseProgress = collapseProgress,
|
||||
expansionProgress = 0f,
|
||||
statusBarHeight = statusBarHeight,
|
||||
headerHeight = headerHeight,
|
||||
hasAvatar = hasAvatar,
|
||||
avatarColor = avatarColors.backgroundColor,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
if (hasAvatar && avatarRepository != null) {
|
||||
OtherProfileFullSizeAvatar(
|
||||
publicKey = publicKey,
|
||||
avatarRepository = avatarRepository,
|
||||
isDarkTheme = isDarkTheme
|
||||
)
|
||||
} else {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().background(avatarColors.backgroundColor),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = getInitials(name),
|
||||
fontSize = avatarFontSize,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = avatarColors.textColor
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Layer 2: Expanding avatar (fades in when pulling down)
|
||||
if (expansionAvatarAlpha > 0.01f) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(width = expandedAvatarWidth, height = expandedAvatarHeight)
|
||||
.graphicsLayer {
|
||||
translationX = expandedAvatarXPx
|
||||
translationY = expandedAvatarYPx
|
||||
alpha = avatarAlpha * expansionAvatarAlpha
|
||||
shape = RoundedCornerShape(cornerRadius)
|
||||
clip = true
|
||||
}
|
||||
.background(avatarColors.backgroundColor),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (hasAvatar && avatarRepository != null) {
|
||||
OtherProfileFullSizeAvatar(
|
||||
publicKey = publicKey,
|
||||
avatarRepository = avatarRepository,
|
||||
isDarkTheme = isDarkTheme
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = getInitials(name),
|
||||
fontSize = avatarFontSize,
|
||||
|
||||
@@ -12,11 +12,13 @@ import androidx.compose.animation.*
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
@@ -25,6 +27,7 @@ import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -48,6 +51,7 @@ import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.Velocity
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.launch
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.palette.graphics.Palette as AndroidPalette
|
||||
import com.rosetta.messenger.biometric.BiometricAuthManager
|
||||
@@ -58,8 +62,13 @@ import com.rosetta.messenger.ui.components.BlurredAvatarBackground
|
||||
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
||||
import com.rosetta.messenger.ui.onboarding.PrimaryBlueDark
|
||||
|
||||
import com.rosetta.messenger.ui.components.metaball.ProfileMetaballEffect
|
||||
import com.rosetta.messenger.utils.AvatarFileManager
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.foundation.gestures.detectVerticalDragGestures
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import com.rosetta.messenger.utils.ImageCropHelper
|
||||
import compose.icons.TablerIcons
|
||||
import compose.icons.tablericons.*
|
||||
@@ -292,6 +301,11 @@ fun ProfileScreen(
|
||||
// 🖼️ Состояние для нашего кастомного photo picker
|
||||
var showPhotoPicker by remember { mutableStateOf(false) }
|
||||
|
||||
// 🔍 Full-screen avatar viewer state
|
||||
var showAvatarViewer by remember { mutableStateOf(false) }
|
||||
var avatarViewerBitmap by remember { mutableStateOf<android.graphics.Bitmap?>(null) }
|
||||
var avatarViewerTimestamp by remember { mutableStateOf(0L) }
|
||||
|
||||
// URI выбранного изображения (до crop)
|
||||
var selectedImageUri by remember { mutableStateOf<Uri?>(null) }
|
||||
|
||||
@@ -411,13 +425,28 @@ fun ProfileScreen(
|
||||
val collapsedHeightPx = with(density) { (COLLAPSED_HEADER_HEIGHT + statusBarHeight).toPx() }
|
||||
val maxScrollOffset = expandedHeightPx - collapsedHeightPx
|
||||
|
||||
// Track scroll offset for collapsing (скролл вверх = collapse)
|
||||
// Telegram: extraHeight - напрямую от позиции первого элемента
|
||||
var scrollOffset by remember { mutableFloatStateOf(0f) }
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// TELEGRAM ARCHITECTURE: LazyColumn = RecyclerView
|
||||
// Item 0 = spacer высотой с expanded header (как Telegram item 0)
|
||||
// scrollOffset вычисляется из позиции скролла (как Telegram extraHeight)
|
||||
// Контент и хедер двигаются SYNC — один скролл двигает всё
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
val listState = rememberLazyListState()
|
||||
val expandedHeaderDp = with(density) { expandedHeightPx.toDp() }
|
||||
|
||||
// Derive scrollOffset from LazyColumn scroll — как Telegram checkListViewScroll()
|
||||
// item 0 top position → extraHeight
|
||||
val scrollOffset by remember {
|
||||
derivedStateOf {
|
||||
if (listState.firstVisibleItemIndex == 0) {
|
||||
listState.firstVisibleItemScrollOffset.toFloat().coerceAtMost(maxScrollOffset)
|
||||
} else {
|
||||
maxScrollOffset
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate collapse progress (0 = expanded, 1 = collapsed)
|
||||
// Telegram: diff = (extraHeight - actionsHeight) / headerOnlyExtraHeight
|
||||
// Напрямую без derivedStateOf для плавности
|
||||
val collapseProgress = (scrollOffset / maxScrollOffset).coerceIn(0f, 1f)
|
||||
|
||||
// Dynamic header height based on scroll
|
||||
@@ -465,17 +494,15 @@ fun ProfileScreen(
|
||||
// чтобы аватарка сразу заполнилась после порога
|
||||
val currentProgress = (overscrollOffset / maxOverscroll).coerceIn(0f, 1f)
|
||||
|
||||
// 🔥 Плавная spring анимация для snap
|
||||
// Плавная spring анимация для snap (без bounce для гладкости)
|
||||
val animatedOverscroll by animateFloatAsState(
|
||||
targetValue = targetOverscroll,
|
||||
animationSpec = if (isDragging && !isPulledDown) {
|
||||
// Без анимации во время drag (до snap)
|
||||
spring(stiffness = Spring.StiffnessHigh)
|
||||
} else {
|
||||
// Плавная анимация для snap
|
||||
spring(
|
||||
dampingRatio = Spring.DampingRatioLowBouncy, // Лёгкий bounce
|
||||
stiffness = Spring.StiffnessMediumLow // Плавное движение
|
||||
dampingRatio = Spring.DampingRatioNoBouncy,
|
||||
stiffness = Spring.StiffnessMedium
|
||||
)
|
||||
},
|
||||
label = "overscroll"
|
||||
@@ -526,50 +553,32 @@ fun ProfileScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// DEBUG LOGS
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// NESTED SCROLL - Telegram style
|
||||
// NESTED SCROLL — overscroll (pull-down аватарка) + header snap
|
||||
// Header collapse управляется скроллом LazyColumn (Telegram RecyclerView)
|
||||
// Snap вызывается в onPreFling — СРАЗУ при отпускании пальца
|
||||
// (как Telegram ACTION_UP → smoothScrollBy)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
val nestedScrollConnection = remember {
|
||||
object : NestedScrollConnection {
|
||||
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
|
||||
val delta = available.y
|
||||
isDragging = true
|
||||
|
||||
// Тянем вверх (delta < 0)
|
||||
// Тянем вверх (delta < 0) — убираем overscroll
|
||||
if (delta < 0) {
|
||||
// Сначала убираем overscroll
|
||||
if (overscrollOffset > 0 || isPulledDown) {
|
||||
// 🔥 FIX: Если isPulledDown=true - НЕ меняем overscrollOffset напрямую
|
||||
// Только сбрасываем isPulledDown и даём анимации плавно свернуть
|
||||
isDragging = true
|
||||
if (isPulledDown) {
|
||||
// При достаточном свайпе вверх - плавно сворачиваем
|
||||
if (delta < -10f) {
|
||||
isPulledDown = false
|
||||
// 🔥 НЕ сбрасываем overscrollOffset - пусть animatedOverscroll плавно вернётся к 0
|
||||
}
|
||||
return Offset(0f, delta) // Consume весь delta
|
||||
return Offset(0f, delta)
|
||||
}
|
||||
val newOffset = (overscrollOffset + delta).coerceAtLeast(0f)
|
||||
val consumed = overscrollOffset - newOffset
|
||||
overscrollOffset = newOffset
|
||||
return Offset(0f, -consumed)
|
||||
}
|
||||
// Затем коллапсируем header
|
||||
if (scrollOffset < maxScrollOffset) {
|
||||
val newScrollOffset = (scrollOffset - delta).coerceIn(0f, maxScrollOffset)
|
||||
val consumed = newScrollOffset - scrollOffset
|
||||
scrollOffset = newScrollOffset
|
||||
return Offset(0f, -consumed)
|
||||
}
|
||||
}
|
||||
|
||||
// Тянем вниз (delta > 0) - раскрываем header
|
||||
if (delta > 0 && scrollOffset > 0) {
|
||||
val newScrollOffset = (scrollOffset - delta).coerceAtLeast(0f)
|
||||
val consumed = scrollOffset - newScrollOffset
|
||||
scrollOffset = newScrollOffset
|
||||
return Offset(0f, consumed)
|
||||
}
|
||||
|
||||
return Offset.Zero
|
||||
@@ -580,9 +589,9 @@ fun ProfileScreen(
|
||||
available: Offset,
|
||||
source: NestedScrollSource
|
||||
): Offset {
|
||||
// Overscroll при свайпе вниз от верха
|
||||
// Overscroll при свайпе вниз от верха (когда LazyColumn в начале)
|
||||
if (available.y > 0 && scrollOffset == 0f) {
|
||||
// Telegram: сопротивление если ещё не isPulledDown
|
||||
isDragging = true
|
||||
val resistance = if (isPulledDown) 1f else 0.5f
|
||||
val delta = available.y * resistance
|
||||
overscrollOffset = (overscrollOffset + delta).coerceAtMost(maxOverscroll)
|
||||
@@ -591,34 +600,56 @@ fun ProfileScreen(
|
||||
return Offset.Zero
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// onPreFling — Telegram ACTION_UP
|
||||
// Вызывается СРАЗУ при отпускании пальца, ДО fling.
|
||||
// Если хедер в промежуточной позиции — перехватываем
|
||||
// velocity и запускаем animateScrollToItem.
|
||||
// LazyColumn НЕ получит velocity → нет fling → нет дёрганья.
|
||||
// ═══════════════════════════════════════════════════════
|
||||
override suspend fun onPreFling(available: Velocity): Velocity {
|
||||
lastVelocity = available.y
|
||||
return Velocity.Zero
|
||||
}
|
||||
|
||||
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
|
||||
isDragging = false
|
||||
|
||||
// Telegram: snap логика с учётом velocity
|
||||
// Если velocity > 1000 и тянем вниз - snap to expanded даже если < 33%
|
||||
// Если velocity < -1000 и тянем вверх - snap to collapsed даже если > 33%
|
||||
// Overscroll snap (аватарка pull-down)
|
||||
val velocityThreshold = 1000f
|
||||
|
||||
when {
|
||||
overscrollOffset > snapThreshold || (lastVelocity > velocityThreshold && overscrollOffset > snapThreshold * 0.5f) -> {
|
||||
// Snap to expanded
|
||||
isPulledDown = true
|
||||
}
|
||||
lastVelocity < -velocityThreshold && overscrollOffset > 0 -> {
|
||||
// Fast swipe up - snap to collapsed
|
||||
isPulledDown = false
|
||||
}
|
||||
else -> {
|
||||
// Normal case - snap based on threshold
|
||||
isPulledDown = overscrollOffset > snapThreshold
|
||||
}
|
||||
}
|
||||
|
||||
// HEADER SNAP — как Telegram smoothScrollBy в ACTION_UP
|
||||
val currentOffset = scrollOffset
|
||||
if (currentOffset > 0f && currentOffset < maxScrollOffset) {
|
||||
val progress = currentOffset / maxScrollOffset
|
||||
val snapToCollapsed = when {
|
||||
available.y < -velocityThreshold -> true
|
||||
available.y > velocityThreshold -> false
|
||||
progress >= 0.6f -> true
|
||||
else -> false
|
||||
}
|
||||
if (snapToCollapsed) {
|
||||
// Snap to collapsed — доскроллить spacer вверх
|
||||
listState.animateScrollToItem(0, maxScrollOffset.toInt())
|
||||
} else {
|
||||
// Snap to expanded — вернуть spacer в начало
|
||||
listState.animateScrollToItem(0, 0)
|
||||
}
|
||||
// Поглощаем velocity — LazyColumn не fling'ит
|
||||
return available
|
||||
}
|
||||
|
||||
return Velocity.Zero
|
||||
}
|
||||
|
||||
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
|
||||
return Velocity.Zero
|
||||
}
|
||||
}
|
||||
@@ -693,8 +724,18 @@ fun ProfileScreen(
|
||||
.background(backgroundColor)
|
||||
.nestedScroll(nestedScrollConnection)
|
||||
) {
|
||||
// Scrollable content
|
||||
LazyColumn(modifier = Modifier.fillMaxSize().padding(top = headerHeight)) {
|
||||
// Scrollable content — Telegram architecture:
|
||||
// Item 0 = spacer (как Telegram RecyclerView item 0)
|
||||
// Скролл LazyColumn двигает КОНТЕНТ + хедер вместе
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
// Item 0: spacer высотой с раскрытый хедер
|
||||
// Когда скроллим вверх — spacer уходит, scrollOffset растёт
|
||||
item {
|
||||
Spacer(modifier = Modifier.fillMaxWidth().height(expandedHeaderDp))
|
||||
}
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
@@ -902,7 +943,23 @@ fun ProfileScreen(
|
||||
},
|
||||
hasAvatar = hasAvatar,
|
||||
avatarRepository = avatarRepository,
|
||||
backgroundBlurColorId = backgroundBlurColorId
|
||||
backgroundBlurColorId = backgroundBlurColorId,
|
||||
onAvatarLongPress = {
|
||||
if (hasAvatar) {
|
||||
scope.launch {
|
||||
val avatarList = avatarRepository?.getAvatars(accountPublicKey, allDecode = false)?.first()
|
||||
val first = avatarList?.firstOrNull()
|
||||
if (first != null) {
|
||||
avatarViewerTimestamp = first.timestamp
|
||||
val bmp = withContext(Dispatchers.IO) {
|
||||
AvatarFileManager.base64ToBitmap(first.base64Data)
|
||||
}
|
||||
avatarViewerBitmap = bmp
|
||||
showAvatarViewer = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -918,6 +975,17 @@ fun ProfileScreen(
|
||||
},
|
||||
isDarkTheme = isDarkTheme
|
||||
)
|
||||
|
||||
// 🔍 Full-screen avatar viewer
|
||||
FullScreenAvatarViewer(
|
||||
isVisible = showAvatarViewer,
|
||||
onDismiss = { showAvatarViewer = false },
|
||||
displayName = editedName.ifBlank { accountPublicKey.take(10) },
|
||||
avatarTimestamp = avatarViewerTimestamp,
|
||||
avatarBitmap = avatarViewerBitmap,
|
||||
publicKey = accountPublicKey,
|
||||
isDarkTheme = isDarkTheme
|
||||
)
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════
|
||||
@@ -945,7 +1013,8 @@ private fun CollapsingProfileHeader(
|
||||
onDeletePhotoClick: () -> Unit,
|
||||
hasAvatar: Boolean,
|
||||
avatarRepository: AvatarRepository?,
|
||||
backgroundBlurColorId: String = "avatar"
|
||||
backgroundBlurColorId: String = "avatar",
|
||||
onAvatarLongPress: () -> Unit = {}
|
||||
) {
|
||||
@Suppress("UNUSED_VARIABLE")
|
||||
val density = LocalDensity.current
|
||||
@@ -987,35 +1056,102 @@ private fun CollapsingProfileHeader(
|
||||
val nameFontSize = androidx.compose.ui.unit.lerp(24.sp, 18.sp, collapseProgress)
|
||||
val onlineFontSize = androidx.compose.ui.unit.lerp(14.sp, 13.sp, collapseProgress)
|
||||
|
||||
Box(modifier = Modifier.fillMaxWidth().height(headerHeight)) {
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 🎨 BLURRED AVATAR BACKGROUND - всегда показываем
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
BlurredAvatarBackground(
|
||||
publicKey = publicKey,
|
||||
avatarRepository = avatarRepository,
|
||||
fallbackColor = avatarColors.backgroundColor,
|
||||
blurRadius = 20f,
|
||||
alpha = 0.9f,
|
||||
overlayColors = BackgroundBlurPresets.getOverlayColors(backgroundBlurColorId),
|
||||
isDarkTheme = isDarkTheme
|
||||
)
|
||||
Box(modifier = Modifier.fillMaxWidth().height(headerHeight).clipToBounds()) {
|
||||
// Expansion fraction — computed early so gradient can fade during expansion
|
||||
val expandFraction = expansionProgress.coerceIn(0f, 1f)
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 👤 AVATAR with METABALL EFFECT - Liquid merge animation on scroll
|
||||
// При скролле вверх аватарка "сливается" с Dynamic Island
|
||||
// Используем metaball эффект для плавного слияния форм
|
||||
// 🎨 BLURRED AVATAR BACKGROUND — ВСЕГДА видим
|
||||
// Не fadeout'им при расширении: аватарка растёт поверх blur'а
|
||||
// и естественно перекрывает его. Без мерцания.
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
ProfileMetaballEffect(
|
||||
collapseProgress = collapseProgress,
|
||||
expansionProgress = expansionProgress,
|
||||
statusBarHeight = statusBarHeight,
|
||||
headerHeight = headerHeight,
|
||||
hasAvatar = hasAvatar,
|
||||
avatarColor = avatarColors.backgroundColor,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
Box(modifier = Modifier.matchParentSize()) {
|
||||
BlurredAvatarBackground(
|
||||
publicKey = publicKey,
|
||||
avatarRepository = avatarRepository,
|
||||
fallbackColor = avatarColors.backgroundColor,
|
||||
blurRadius = 20f,
|
||||
alpha = 0.9f,
|
||||
overlayColors = BackgroundBlurPresets.getOverlayColors(backgroundBlurColorId),
|
||||
isDarkTheme = isDarkTheme
|
||||
)
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 🌅 BOTTOM GRADIENT — плавно исчезает только когда аватарка
|
||||
// уже почти заполнила всю область (90%+)
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
val gradientAlpha = (1f - expandFraction * 1.2f).coerceIn(0f, 1f)
|
||||
if (gradientAlpha > 0.01f) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(80.dp)
|
||||
.align(Alignment.BottomCenter)
|
||||
.graphicsLayer { alpha = gradientAlpha }
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
Color.Transparent,
|
||||
Color.Black.copy(alpha = 0.35f)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 👤 AVATAR — Telegram-style expansion to fill header on pull-down
|
||||
// Normal: circle, centered. Pull-down: grows to fill full header width
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
val avatarSize = androidx.compose.ui.unit.lerp(
|
||||
AVATAR_SIZE_EXPANDED, AVATAR_SIZE_COLLAPSED, collapseProgress
|
||||
)
|
||||
val avatarAlpha = (1f - collapseProgress * 1.8f).coerceIn(0f, 1f)
|
||||
val avatarShadowSize = androidx.compose.ui.unit.lerp(12.dp, 0.dp, collapseProgress)
|
||||
val contentAreaHeight = EXPANDED_HEADER_HEIGHT - 70.dp
|
||||
val avatarExpandedY = statusBarHeight + (contentAreaHeight - AVATAR_SIZE_EXPANDED) / 2
|
||||
val avatarCollapsedY = statusBarHeight + (COLLAPSED_HEADER_HEIGHT - AVATAR_SIZE_COLLAPSED) / 2
|
||||
val avatarY = androidx.compose.ui.unit.lerp(avatarExpandedY, avatarCollapsedY, collapseProgress)
|
||||
|
||||
// Telegram-style expansion math
|
||||
val screenWidth = LocalConfiguration.current.screenWidthDp.dp
|
||||
// Avatar width: circle → full screen width
|
||||
val expandedAvatarWidth = androidx.compose.ui.unit.lerp(avatarSize, screenWidth, expandFraction)
|
||||
// Avatar height: circle → full header height
|
||||
val expandedAvatarHeight = androidx.compose.ui.unit.lerp(avatarSize, headerHeight, expandFraction)
|
||||
// Corner radius: fully circular → 0 (square)
|
||||
val cornerRadius = androidx.compose.ui.unit.lerp(avatarSize / 2, 0.dp, expandFraction)
|
||||
// Avatar X: centered → left edge
|
||||
val avatarCenterX = (screenWidth - avatarSize) / 2
|
||||
val expandedAvatarX = androidx.compose.ui.unit.lerp(avatarCenterX, 0.dp, expandFraction)
|
||||
// Avatar Y: normal position → top
|
||||
val expandedAvatarY = androidx.compose.ui.unit.lerp(avatarY, 0.dp, expandFraction)
|
||||
|
||||
// Pre-compute pixel values for graphicsLayer (avoids layout-phase recomposition)
|
||||
val expandedAvatarXPx = with(density) { expandedAvatarX.toPx() }
|
||||
val expandedAvatarYPx = with(density) { expandedAvatarY.toPx() }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(width = expandedAvatarWidth, height = expandedAvatarHeight)
|
||||
.graphicsLayer {
|
||||
translationX = expandedAvatarXPx
|
||||
translationY = expandedAvatarYPx
|
||||
alpha = avatarAlpha
|
||||
shadowElevation = if (expansionProgress > 0.01f) 0f
|
||||
else with(density) { avatarShadowSize.toPx() }
|
||||
shape = RoundedCornerShape(cornerRadius)
|
||||
clip = true
|
||||
}
|
||||
.background(avatarColors.backgroundColor)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onLongPress = { onAvatarLongPress() }
|
||||
)
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
// Содержимое аватара
|
||||
if (hasAvatar && avatarRepository != null) {
|
||||
FullSizeAvatar(
|
||||
publicKey = publicKey,
|
||||
@@ -1023,23 +1159,51 @@ private fun CollapsingProfileHeader(
|
||||
isDarkTheme = isDarkTheme
|
||||
)
|
||||
} else {
|
||||
// Placeholder без аватарки
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().background(avatarColors.backgroundColor),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = getInitials(name),
|
||||
fontSize = avatarFontSize,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = avatarColors.textColor
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = getInitials(name),
|
||||
fontSize = avatarFontSize,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = avatarColors.textColor
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 🔙 BACK BUTTON
|
||||
// <EFBFBD> ADD/CHANGE AVATAR BUTTON — bottom-right of avatar circle
|
||||
// Fades out on collapse and expansion
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
val cameraButtonAlpha = avatarAlpha * (1f - expandFraction * 4f).coerceIn(0f, 1f)
|
||||
if (cameraButtonAlpha > 0.01f) {
|
||||
val cameraButtonSize = 44.dp
|
||||
// Position: bottom-right of the avatar circle
|
||||
val avatarCenterXPos = screenWidth / 2
|
||||
val avatarCenterYPos = avatarY + avatarSize / 2
|
||||
// Offset to bottom-right edge of circle (45° from center)
|
||||
val offsetFromCenter = avatarSize / 2 * 0.7f // cos(45°) ≈ 0.707
|
||||
val cameraX = avatarCenterXPos + offsetFromCenter - cameraButtonSize / 2
|
||||
val cameraY = avatarCenterYPos + offsetFromCenter - cameraButtonSize / 2
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.offset(x = cameraX, y = cameraY)
|
||||
.size(cameraButtonSize)
|
||||
.graphicsLayer { alpha = cameraButtonAlpha }
|
||||
.clip(CircleShape)
|
||||
.background(Color(0xFF3A3A3C))
|
||||
.clickable { onSetPhotoClick() },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = TablerIcons.CameraPlus,
|
||||
contentDescription = "Change avatar",
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(22.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// <20>🔙 BACK BUTTON
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
Box(
|
||||
modifier =
|
||||
@@ -1762,3 +1926,198 @@ fun ProfileNavigationItem(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════
|
||||
// 🖼️ FULL SCREEN AVATAR VIEWER — Telegram style
|
||||
// Long-press avatar → full screen with swipe-down to dismiss
|
||||
// ═════════════════════════════════════════════════════════════
|
||||
@Composable
|
||||
fun FullScreenAvatarViewer(
|
||||
isVisible: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
displayName: String,
|
||||
avatarTimestamp: Long,
|
||||
avatarBitmap: android.graphics.Bitmap?,
|
||||
publicKey: String,
|
||||
isDarkTheme: Boolean
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
val screenHeight = with(density) { LocalConfiguration.current.screenHeightDp.dp.toPx() }
|
||||
|
||||
// Animated visibility
|
||||
var showContent by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(isVisible) {
|
||||
showContent = isVisible
|
||||
}
|
||||
|
||||
// Swipe-to-dismiss offset
|
||||
var dragOffsetY by remember { mutableFloatStateOf(0f) }
|
||||
var isDragging by remember { mutableStateOf(false) }
|
||||
val dismissThreshold = screenHeight * 0.25f
|
||||
|
||||
// Animated values
|
||||
val animatedAlpha by animateFloatAsState(
|
||||
targetValue = if (showContent) {
|
||||
val dragProgress = (kotlin.math.abs(dragOffsetY) / dismissThreshold).coerceIn(0f, 1f)
|
||||
1f - dragProgress * 0.6f
|
||||
} else 0f,
|
||||
animationSpec = tween(if (showContent && !isDragging) 250 else 200),
|
||||
label = "bg_alpha"
|
||||
)
|
||||
|
||||
val animatedScale by animateFloatAsState(
|
||||
targetValue = if (showContent) {
|
||||
val dragProgress = (kotlin.math.abs(dragOffsetY) / dismissThreshold).coerceIn(0f, 1f)
|
||||
1f - dragProgress * 0.15f
|
||||
} else 0.8f,
|
||||
animationSpec = tween(if (showContent && !isDragging) 250 else 200),
|
||||
label = "scale"
|
||||
)
|
||||
|
||||
val animatedOffset by animateFloatAsState(
|
||||
targetValue = if (isDragging) dragOffsetY else if (showContent) 0f else 0f,
|
||||
animationSpec = if (isDragging) spring(stiffness = Spring.StiffnessHigh)
|
||||
else spring(dampingRatio = Spring.DampingRatioMediumBouncy),
|
||||
label = "offset",
|
||||
finishedListener = {
|
||||
if (!showContent) onDismiss()
|
||||
}
|
||||
)
|
||||
|
||||
// Date formatting
|
||||
val dateText = remember(avatarTimestamp) {
|
||||
if (avatarTimestamp > 0) {
|
||||
val sdf = SimpleDateFormat("MMMM d, yyyy 'at' h:mm a", Locale.ENGLISH)
|
||||
sdf.format(Date(avatarTimestamp * 1000))
|
||||
} else ""
|
||||
}
|
||||
|
||||
if (isVisible) {
|
||||
BackHandler { showContent = false }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.graphicsLayer { alpha = animatedAlpha }
|
||||
.background(Color.Black)
|
||||
.pointerInput(Unit) {
|
||||
detectVerticalDragGestures(
|
||||
onDragStart = { isDragging = true },
|
||||
onDragEnd = {
|
||||
isDragging = false
|
||||
if (kotlin.math.abs(dragOffsetY) > dismissThreshold) {
|
||||
showContent = false
|
||||
}
|
||||
dragOffsetY = 0f
|
||||
},
|
||||
onDragCancel = {
|
||||
isDragging = false
|
||||
dragOffsetY = 0f
|
||||
},
|
||||
onVerticalDrag = { _, dragAmount ->
|
||||
dragOffsetY += dragAmount
|
||||
}
|
||||
)
|
||||
}
|
||||
) {
|
||||
// Avatar image centered
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.graphicsLayer {
|
||||
translationY = animatedOffset
|
||||
scaleX = animatedScale
|
||||
scaleY = animatedScale
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (avatarBitmap != null) {
|
||||
Image(
|
||||
bitmap = avatarBitmap.asImageBitmap(),
|
||||
contentDescription = "Avatar",
|
||||
modifier = Modifier.fillMaxWidth().aspectRatio(1f),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
} else {
|
||||
val avatarColors = getAvatarColor(publicKey, isDarkTheme)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
.background(avatarColors.backgroundColor),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = getInitials(displayName.ifBlank { publicKey.take(6) }),
|
||||
fontSize = 80.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = avatarColors.textColor
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Top gradient for readability
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(120.dp)
|
||||
.align(Alignment.TopCenter)
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
Color.Black.copy(alpha = 0.6f),
|
||||
Color.Transparent
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
// Header: back button + name + date
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.statusBarsPadding()
|
||||
.padding(horizontal = 4.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
IconButton(onClick = { showContent = false }) {
|
||||
Icon(
|
||||
imageVector = TablerIcons.ArrowLeft,
|
||||
contentDescription = "Close",
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = displayName.ifBlank { publicKey.take(10) },
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color.White,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
if (dateText.isNotEmpty()) {
|
||||
Text(
|
||||
text = dateText,
|
||||
fontSize = 13.sp,
|
||||
color = Color.White.copy(alpha = 0.7f),
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
IconButton(onClick = { /* menu */ }) {
|
||||
Icon(
|
||||
imageVector = TablerIcons.DotsVertical,
|
||||
contentDescription = "Menu",
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ android.useAndroidX=true
|
||||
kotlin.code.style=official
|
||||
|
||||
# Use Java 17 for build
|
||||
org.gradle.java.home=/Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home
|
||||
org.gradle.java.home=/opt/homebrew/opt/openjdk@17
|
||||
|
||||
# Increase heap size for Gradle
|
||||
org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError --add-opens=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.jvm=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED
|
||||
|
||||
Reference in New Issue
Block a user