feat: enhance RequestsSection layout and add full-screen avatar viewer in ProfileScreen

This commit is contained in:
2026-02-13 13:39:00 +05:00
parent 899d79c9fd
commit e17b03c1c5
6 changed files with 660 additions and 234 deletions

View File

@@ -627,6 +627,7 @@ fun ChatsListScreen(
iconColor = menuIconColor, iconColor = menuIconColor,
textColor = textColor, textColor = textColor,
badge = if (topLevelRequestsCount > 0) topLevelRequestsCount.toString() else null, badge = if (topLevelRequestsCount > 0) topLevelRequestsCount.toString() else null,
badgeColor = accentColor,
onClick = { onClick = {
scope.launch { scope.launch {
drawerState.close() drawerState.close()
@@ -913,7 +914,7 @@ fun ChatsListScreen(
.offset(x = 2.dp, y = (-2).dp) .offset(x = 2.dp, y = (-2).dp)
.size(8.dp) .size(8.dp)
.clip(CircleShape) .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 @Composable
fun RequestsSection( fun RequestsSection(
count: Int, count: Int,
@@ -2883,6 +2884,7 @@ fun RequestsSection(
remember(isDarkTheme) { if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) } remember(isDarkTheme) { if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) }
val iconBgColor = val iconBgColor =
remember(isDarkTheme) { if (isDarkTheme) Color(0xFF3A3A3C) else Color(0xFFC7C7CC) } remember(isDarkTheme) { if (isDarkTheme) Color(0xFF3A3A3C) else Color(0xFFC7C7CC) }
val accentColor = if (isDarkTheme) PrimaryBlueDark else PrimaryBlue
// Последний запрос — показываем имя отправителя как subtitle // Последний запрос — показываем имя отправителя как subtitle
val lastRequest = remember(requests) { requests.firstOrNull() } val lastRequest = remember(requests) { requests.firstOrNull() }
@@ -2899,74 +2901,58 @@ fun RequestsSection(
} }
} }
Row( Column {
modifier = Row(
Modifier.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Иконка — круглый аватар как в Telegram Archived Chats
Box(
modifier = modifier =
Modifier.size(56.dp) Modifier.fillMaxWidth()
.clip(CircleShape) .clickable(onClick = onClick)
.background(iconBgColor), .padding(horizontal = 16.dp, vertical = 12.dp),
contentAlignment = Alignment.Center verticalAlignment = Alignment.CenterVertically
) { ) {
Icon( // Иконка — круглый аватар как Archived Chats в Telegram
imageVector = TablerIcons.MailForward, Box(
contentDescription = null, modifier =
tint = Color.White, Modifier.size(56.dp)
modifier = Modifier.size(26.dp) .clip(CircleShape)
) .background(iconBgColor),
} contentAlignment = Alignment.Center
Spacer(modifier = Modifier.width(12.dp))
// Текст: название + последний отправитель
Column(modifier = Modifier.weight(1f)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) { ) {
Text( Icon(
text = "Requests", imageVector = TablerIcons.MailForward,
fontWeight = FontWeight.SemiBold, contentDescription = null,
fontSize = 16.sp, tint = Color.White,
color = textColor, modifier = Modifier.size(26.dp)
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
) )
} }
if (subtitle.isNotEmpty()) { Spacer(modifier = Modifier.width(12.dp))
Spacer(modifier = Modifier.height(4.dp))
Column(modifier = Modifier.weight(1f)) {
// Верхняя строка: название + badge
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text( Text(
text = subtitle, text = "Requests",
fontSize = 14.sp, fontWeight = FontWeight.SemiBold,
color = secondaryTextColor, fontSize = 16.sp,
color = textColor,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
// Badge с количеством // Badge справа на уровне заголовка
if (count > 0) { if (count > 0) {
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Box( Box(
modifier = modifier =
Modifier Modifier
.defaultMinSize(minWidth = 22.dp, minHeight = 22.dp) .defaultMinSize(minWidth = 22.dp, minHeight = 22.dp)
.clip(RoundedCornerShape(11.dp)) .clip(CircleShape)
.background(Color(0xFFE53935)), .background(accentColor),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text( Text(
@@ -2974,37 +2960,33 @@ fun RequestsSection(
fontSize = 12.sp, fontSize = 12.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = Color.White, 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 // Нижняя строка: subtitle (последний запрос)
Spacer(modifier = Modifier.height(4.dp)) if (subtitle.isNotEmpty()) {
Row( Spacer(modifier = Modifier.height(4.dp))
modifier = Modifier.fillMaxWidth(), Text(
horizontalArrangement = Arrangement.End text = subtitle,
) { fontSize = 14.sp,
Box( color = secondaryTextColor,
modifier = maxLines = 1,
Modifier overflow = TextOverflow.Ellipsis
.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)
)
}
} }
} }
} }
// Разделитель как у обычных чатов
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, iconColor: Color,
textColor: Color, textColor: Color,
badge: String? = null, badge: String? = null,
badgeColor: Color = Color(0xFFE53935),
onClick: () -> Unit onClick: () -> Unit
) { ) {
Row( Row(
@@ -3237,17 +3220,21 @@ fun DrawerMenuItemEnhanced(
badge?.let { badge?.let {
Box( Box(
modifier = modifier =
Modifier.background( Modifier
color = Color(0xFFE53935), .defaultMinSize(minWidth = 22.dp, minHeight = 22.dp)
shape = RoundedCornerShape(10.dp) .background(
) color = badgeColor,
.padding(horizontal = 8.dp, vertical = 2.dp) shape = CircleShape
),
contentAlignment = Alignment.Center
) { ) {
Text( Text(
text = it, text = it,
fontSize = 12.sp, fontSize = 12.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Bold,
color = Color.White color = Color.White,
lineHeight = 12.sp,
modifier = Modifier.padding(horizontal = 5.dp, vertical = 3.dp)
) )
} }
} }

View File

@@ -42,6 +42,7 @@ fun RequestsListScreen(
val blockedUsers by chatsViewModel.blockedUsers.collectAsState() val blockedUsers by chatsViewModel.blockedUsers.collectAsState()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7) 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 val textColor = if (isDarkTheme) Color.White else Color.Black
Scaffold( Scaffold(
@@ -61,15 +62,16 @@ fun RequestsListScreen(
text = "Requests", text = "Requests",
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
fontSize = 20.sp, fontSize = 20.sp,
color = textColor color = Color.White
) )
}, },
colors = colors =
TopAppBarDefaults.topAppBarColors( TopAppBarDefaults.topAppBarColors(
containerColor = backgroundColor, containerColor = headerColor,
scrolledContainerColor = backgroundColor, scrolledContainerColor = headerColor,
navigationIconContentColor = textColor, navigationIconContentColor = Color.White,
titleContentColor = textColor titleContentColor = Color.White,
actionIconContentColor = Color.White
) )
) )
}, },

View File

@@ -77,20 +77,28 @@ fun BoxScope.BlurredAvatarBackground(
val avatars by avatarRepository?.getAvatars(publicKey, allDecode = false)?.collectAsState() val avatars by avatarRepository?.getAvatars(publicKey, allDecode = false)?.collectAsState()
?: remember { mutableStateOf(emptyList()) } ?: remember { mutableStateOf(emptyList()) }
var originalBitmap by remember(avatars) { mutableStateOf<Bitmap?>(null) } // Stable key based on content, not list reference — prevents bitmap reset during recomposition
var blurredBitmap by remember(avatars) { mutableStateOf<Bitmap?>(null) } val avatarKey = remember(avatars) {
avatars.firstOrNull()?.timestamp ?: 0L
}
LaunchedEffect(avatars) { // Don't reset bitmap to null when key changes — keep showing old blur until new one is ready
if (avatars.isNotEmpty()) { var originalBitmap by remember { mutableStateOf<Bitmap?>(null) }
originalBitmap = withContext(Dispatchers.IO) { var blurredBitmap by remember { mutableStateOf<Bitmap?>(null) }
AvatarFileManager.base64ToBitmap(avatars.first().base64Data)
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) { blurredBitmap = withContext(Dispatchers.Default) {
val scaledBitmap = Bitmap.createScaledBitmap( val scaledBitmap = Bitmap.createScaledBitmap(
bitmap, newOriginal,
bitmap.width / 4, newOriginal.width / 4,
bitmap.height / 4, newOriginal.height / 4,
true true
) )
var result = scaledBitmap var result = scaledBitmap

View File

@@ -41,6 +41,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
@@ -368,17 +369,15 @@ fun OtherProfileScreen(
else -> 0f else -> 0f
} }
// 🔥 Плавная spring анимация для snap // Плавная spring анимация для snap (без bounce для гладкости)
val animatedOverscroll by animateFloatAsState( val animatedOverscroll by animateFloatAsState(
targetValue = targetOverscroll, targetValue = targetOverscroll,
animationSpec = if (isDragging && !isPulledDown) { animationSpec = if (isDragging && !isPulledDown) {
// Без анимации во время drag (до snap)
spring(stiffness = Spring.StiffnessHigh) spring(stiffness = Spring.StiffnessHigh)
} else { } else {
// Плавная анимация для snap
spring( spring(
dampingRatio = Spring.DampingRatioLowBouncy, // Лёгкий bounce dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = Spring.StiffnessMediumLow // Плавное движение stiffness = Spring.StiffnessMedium
) )
}, },
label = "overscroll" label = "overscroll"
@@ -1674,47 +1673,118 @@ private fun CollapsingOtherProfileHeader(
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
val textColor = Color.White val textColor = Color.White
Box(modifier = Modifier.fillMaxWidth().height(headerHeight)) { Box(modifier = Modifier.fillMaxWidth().height(headerHeight).clipToBounds()) {
// ═══════════════════════════════════════════════════════════ // Expansion fraction — computed early so blur can fade during expansion
// 🎨 BLURRED AVATAR BACKGROUND val expandFractionEarly = expansionProgress.coerceIn(0f, 1f)
// ═══════════════════════════════════════════════════════════ val blurAlpha = (1f - expandFractionEarly * 2.5f).coerceIn(0f, 1f)
BlurredAvatarBackground(
publicKey = publicKey,
avatarRepository = avatarRepository,
fallbackColor = avatarColors.backgroundColor,
blurRadius = 20f,
alpha = 0.9f,
overlayColors = com.rosetta.messenger.ui.settings.BackgroundBlurPresets.getOverlayColors(backgroundBlurColorId),
isDarkTheme = isDarkTheme
)
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
// 👤 AVATAR with METABALL EFFECT - Liquid merge animation // 🎨 BLURRED AVATAR BACKGROUND - fades out during expansion
// При скролле вверх аватарка "сливается" с Dynamic Island
// При свайпе вниз - расширяется на весь экран
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
ProfileMetaballEffect( if (blurAlpha > 0.01f) {
collapseProgress = collapseProgress, Box(modifier = Modifier.matchParentSize().graphicsLayer { alpha = blurAlpha }) {
expansionProgress = expansionProgress, BlurredAvatarBackground(
statusBarHeight = statusBarHeight,
headerHeight = headerHeight,
hasAvatar = hasAvatar,
avatarColor = avatarColors.backgroundColor,
modifier = Modifier.fillMaxSize()
) {
// Содержимое аватара
if (hasAvatar && avatarRepository != null) {
OtherProfileFullSizeAvatar(
publicKey = publicKey, publicKey = publicKey,
avatarRepository = avatarRepository, avatarRepository = avatarRepository,
fallbackColor = avatarColors.backgroundColor,
blurRadius = 20f,
alpha = 0.9f,
overlayColors = com.rosetta.messenger.ui.settings.BackgroundBlurPresets.getOverlayColors(backgroundBlurColorId),
isDarkTheme = isDarkTheme 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(
text = getInitials(name), text = getInitials(name),
fontSize = avatarFontSize, fontSize = avatarFontSize,

View File

@@ -12,11 +12,13 @@ import androidx.compose.animation.*
import androidx.compose.animation.core.* import androidx.compose.animation.core.*
import androidx.compose.foundation.* import androidx.compose.foundation.*
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.BasicTextField
@@ -25,6 +27,7 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.draw.shadow import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color 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.Velocity
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 kotlinx.coroutines.launch
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
import androidx.palette.graphics.Palette as AndroidPalette import androidx.palette.graphics.Palette as AndroidPalette
import com.rosetta.messenger.biometric.BiometricAuthManager 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.PrimaryBlue
import com.rosetta.messenger.ui.onboarding.PrimaryBlueDark import com.rosetta.messenger.ui.onboarding.PrimaryBlueDark
import com.rosetta.messenger.ui.components.metaball.ProfileMetaballEffect
import com.rosetta.messenger.utils.AvatarFileManager 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 com.rosetta.messenger.utils.ImageCropHelper
import compose.icons.TablerIcons import compose.icons.TablerIcons
import compose.icons.tablericons.* import compose.icons.tablericons.*
@@ -292,6 +301,11 @@ fun ProfileScreen(
// 🖼️ Состояние для нашего кастомного photo picker // 🖼️ Состояние для нашего кастомного photo picker
var showPhotoPicker by remember { mutableStateOf(false) } 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) // URI выбранного изображения (до crop)
var selectedImageUri by remember { mutableStateOf<Uri?>(null) } var selectedImageUri by remember { mutableStateOf<Uri?>(null) }
@@ -411,13 +425,28 @@ fun ProfileScreen(
val collapsedHeightPx = with(density) { (COLLAPSED_HEADER_HEIGHT + statusBarHeight).toPx() } val collapsedHeightPx = with(density) { (COLLAPSED_HEADER_HEIGHT + statusBarHeight).toPx() }
val maxScrollOffset = expandedHeightPx - collapsedHeightPx val maxScrollOffset = expandedHeightPx - collapsedHeightPx
// Track scroll offset for collapsing (скролл вверх = collapse) // ═══════════════════════════════════════════════════════════════
// Telegram: extraHeight - напрямую от позиции первого элемента // TELEGRAM ARCHITECTURE: LazyColumn = RecyclerView
var scrollOffset by remember { mutableFloatStateOf(0f) } // 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) // Calculate collapse progress (0 = expanded, 1 = collapsed)
// Telegram: diff = (extraHeight - actionsHeight) / headerOnlyExtraHeight
// Напрямую без derivedStateOf для плавности
val collapseProgress = (scrollOffset / maxScrollOffset).coerceIn(0f, 1f) val collapseProgress = (scrollOffset / maxScrollOffset).coerceIn(0f, 1f)
// Dynamic header height based on scroll // Dynamic header height based on scroll
@@ -465,17 +494,15 @@ fun ProfileScreen(
// чтобы аватарка сразу заполнилась после порога // чтобы аватарка сразу заполнилась после порога
val currentProgress = (overscrollOffset / maxOverscroll).coerceIn(0f, 1f) val currentProgress = (overscrollOffset / maxOverscroll).coerceIn(0f, 1f)
// 🔥 Плавная spring анимация для snap // Плавная spring анимация для snap (без bounce для гладкости)
val animatedOverscroll by animateFloatAsState( val animatedOverscroll by animateFloatAsState(
targetValue = targetOverscroll, targetValue = targetOverscroll,
animationSpec = if (isDragging && !isPulledDown) { animationSpec = if (isDragging && !isPulledDown) {
// Без анимации во время drag (до snap)
spring(stiffness = Spring.StiffnessHigh) spring(stiffness = Spring.StiffnessHigh)
} else { } else {
// Плавная анимация для snap
spring( spring(
dampingRatio = Spring.DampingRatioLowBouncy, // Лёгкий bounce dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = Spring.StiffnessMediumLow // Плавное движение stiffness = Spring.StiffnessMedium
) )
}, },
label = "overscroll" 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 { val nestedScrollConnection = remember {
object : NestedScrollConnection { object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
val delta = available.y val delta = available.y
isDragging = true
// Тянем вверх (delta < 0) // Тянем вверх (delta < 0) — убираем overscroll
if (delta < 0) { if (delta < 0) {
// Сначала убираем overscroll
if (overscrollOffset > 0 || isPulledDown) { if (overscrollOffset > 0 || isPulledDown) {
// 🔥 FIX: Если isPulledDown=true - НЕ меняем overscrollOffset напрямую isDragging = true
// Только сбрасываем isPulledDown и даём анимации плавно свернуть
if (isPulledDown) { if (isPulledDown) {
// При достаточном свайпе вверх - плавно сворачиваем
if (delta < -10f) { if (delta < -10f) {
isPulledDown = false isPulledDown = false
// 🔥 НЕ сбрасываем overscrollOffset - пусть animatedOverscroll плавно вернётся к 0
} }
return Offset(0f, delta) // Consume весь delta return Offset(0f, delta)
} }
val newOffset = (overscrollOffset + delta).coerceAtLeast(0f) val newOffset = (overscrollOffset + delta).coerceAtLeast(0f)
val consumed = overscrollOffset - newOffset val consumed = overscrollOffset - newOffset
overscrollOffset = newOffset overscrollOffset = newOffset
return Offset(0f, -consumed) 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 return Offset.Zero
@@ -580,9 +589,9 @@ fun ProfileScreen(
available: Offset, available: Offset,
source: NestedScrollSource source: NestedScrollSource
): Offset { ): Offset {
// Overscroll при свайпе вниз от верха // Overscroll при свайпе вниз от верха (когда LazyColumn в начале)
if (available.y > 0 && scrollOffset == 0f) { if (available.y > 0 && scrollOffset == 0f) {
// Telegram: сопротивление если ещё не isPulledDown isDragging = true
val resistance = if (isPulledDown) 1f else 0.5f val resistance = if (isPulledDown) 1f else 0.5f
val delta = available.y * resistance val delta = available.y * resistance
overscrollOffset = (overscrollOffset + delta).coerceAtMost(maxOverscroll) overscrollOffset = (overscrollOffset + delta).coerceAtMost(maxOverscroll)
@@ -591,34 +600,56 @@ fun ProfileScreen(
return Offset.Zero return Offset.Zero
} }
// ═══════════════════════════════════════════════════════
// onPreFling — Telegram ACTION_UP
// Вызывается СРАЗУ при отпускании пальца, ДО fling.
// Если хедер в промежуточной позиции — перехватываем
// velocity и запускаем animateScrollToItem.
// LazyColumn НЕ получит velocity → нет fling → нет дёрганья.
// ═══════════════════════════════════════════════════════
override suspend fun onPreFling(available: Velocity): Velocity { override suspend fun onPreFling(available: Velocity): Velocity {
lastVelocity = available.y lastVelocity = available.y
return Velocity.Zero
}
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
isDragging = false isDragging = false
// Telegram: snap логика с учётом velocity // Overscroll snap (аватарка pull-down)
// Если velocity > 1000 и тянем вниз - snap to expanded даже если < 33%
// Если velocity < -1000 и тянем вверх - snap to collapsed даже если > 33%
val velocityThreshold = 1000f val velocityThreshold = 1000f
when { when {
overscrollOffset > snapThreshold || (lastVelocity > velocityThreshold && overscrollOffset > snapThreshold * 0.5f) -> { overscrollOffset > snapThreshold || (lastVelocity > velocityThreshold && overscrollOffset > snapThreshold * 0.5f) -> {
// Snap to expanded
isPulledDown = true isPulledDown = true
} }
lastVelocity < -velocityThreshold && overscrollOffset > 0 -> { lastVelocity < -velocityThreshold && overscrollOffset > 0 -> {
// Fast swipe up - snap to collapsed
isPulledDown = false isPulledDown = false
} }
else -> { else -> {
// Normal case - snap based on threshold
isPulledDown = overscrollOffset > snapThreshold 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 return Velocity.Zero
} }
} }
@@ -693,8 +724,18 @@ fun ProfileScreen(
.background(backgroundColor) .background(backgroundColor)
.nestedScroll(nestedScrollConnection) .nestedScroll(nestedScrollConnection)
) { ) {
// Scrollable content // Scrollable content — Telegram architecture:
LazyColumn(modifier = Modifier.fillMaxSize().padding(top = headerHeight)) { // 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 { item {
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
@@ -902,7 +943,23 @@ fun ProfileScreen(
}, },
hasAvatar = hasAvatar, hasAvatar = hasAvatar,
avatarRepository = avatarRepository, 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 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, onDeletePhotoClick: () -> Unit,
hasAvatar: Boolean, hasAvatar: Boolean,
avatarRepository: AvatarRepository?, avatarRepository: AvatarRepository?,
backgroundBlurColorId: String = "avatar" backgroundBlurColorId: String = "avatar",
onAvatarLongPress: () -> Unit = {}
) { ) {
@Suppress("UNUSED_VARIABLE") @Suppress("UNUSED_VARIABLE")
val density = LocalDensity.current val density = LocalDensity.current
@@ -987,35 +1056,102 @@ private fun CollapsingProfileHeader(
val nameFontSize = androidx.compose.ui.unit.lerp(24.sp, 18.sp, collapseProgress) val nameFontSize = androidx.compose.ui.unit.lerp(24.sp, 18.sp, collapseProgress)
val onlineFontSize = androidx.compose.ui.unit.lerp(14.sp, 13.sp, collapseProgress) val onlineFontSize = androidx.compose.ui.unit.lerp(14.sp, 13.sp, collapseProgress)
Box(modifier = Modifier.fillMaxWidth().height(headerHeight)) { Box(modifier = Modifier.fillMaxWidth().height(headerHeight).clipToBounds()) {
// ═══════════════════════════════════════════════════════════ // Expansion fraction — computed early so gradient can fade during expansion
// 🎨 BLURRED AVATAR BACKGROUND - всегда показываем val expandFraction = expansionProgress.coerceIn(0f, 1f)
// ═══════════════════════════════════════════════════════════
BlurredAvatarBackground(
publicKey = publicKey,
avatarRepository = avatarRepository,
fallbackColor = avatarColors.backgroundColor,
blurRadius = 20f,
alpha = 0.9f,
overlayColors = BackgroundBlurPresets.getOverlayColors(backgroundBlurColorId),
isDarkTheme = isDarkTheme
)
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
// 👤 AVATAR with METABALL EFFECT - Liquid merge animation on scroll // 🎨 BLURRED AVATAR BACKGROUND — ВСЕГДА видим
// При скролле вверх аватарка "сливается" с Dynamic Island // Не fadeout'им при расширении: аватарка растёт поверх blur'а
// Используем metaball эффект для плавного слияния форм // и естественно перекрывает его. Без мерцания.
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
ProfileMetaballEffect( Box(modifier = Modifier.matchParentSize()) {
collapseProgress = collapseProgress, BlurredAvatarBackground(
expansionProgress = expansionProgress, publicKey = publicKey,
statusBarHeight = statusBarHeight, avatarRepository = avatarRepository,
headerHeight = headerHeight, fallbackColor = avatarColors.backgroundColor,
hasAvatar = hasAvatar, blurRadius = 20f,
avatarColor = avatarColors.backgroundColor, alpha = 0.9f,
modifier = Modifier.fillMaxSize() 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) { if (hasAvatar && avatarRepository != null) {
FullSizeAvatar( FullSizeAvatar(
publicKey = publicKey, publicKey = publicKey,
@@ -1023,23 +1159,51 @@ private fun CollapsingProfileHeader(
isDarkTheme = isDarkTheme isDarkTheme = isDarkTheme
) )
} else { } else {
// Placeholder без аватарки Text(
Box( text = getInitials(name),
modifier = Modifier.fillMaxSize().background(avatarColors.backgroundColor), fontSize = avatarFontSize,
contentAlignment = Alignment.Center 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( Box(
modifier = 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)
)
}
}
}
}
}

View File

@@ -7,7 +7,7 @@ android.useAndroidX=true
kotlin.code.style=official kotlin.code.style=official
# Use Java 17 for build # 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 # 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 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