From e17b03c1c58fa72502027f81c768d56fe19cefdc Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Fri, 13 Feb 2026 13:39:00 +0500 Subject: [PATCH] feat: enhance RequestsSection layout and add full-screen avatar viewer in ProfileScreen --- .../messenger/ui/chats/ChatsListScreen.kt | 143 ++--- .../messenger/ui/chats/RequestsListScreen.kt | 12 +- .../ui/components/BlurredAvatarBackground.kt | 28 +- .../ui/settings/OtherProfileScreen.kt | 146 +++-- .../messenger/ui/settings/ProfileScreen.kt | 563 ++++++++++++++---- gradle.properties | 2 +- 6 files changed, 660 insertions(+), 234 deletions(-) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt index b5f465c..929b013 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt @@ -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) ) } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/RequestsListScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/RequestsListScreen.kt index cd1a111..273c22d 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/RequestsListScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/RequestsListScreen.kt @@ -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 ) ) }, diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/BlurredAvatarBackground.kt b/app/src/main/java/com/rosetta/messenger/ui/components/BlurredAvatarBackground.kt index c6bc14a..bb0afa9 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/components/BlurredAvatarBackground.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/components/BlurredAvatarBackground.kt @@ -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(null) } - var blurredBitmap by remember(avatars) { mutableStateOf(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(null) } + var blurredBitmap by remember { mutableStateOf(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 diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt index a82c46f..7c56633 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt @@ -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, diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt index 11a0aab..0bf7c62 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt @@ -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(null) } + var avatarViewerTimestamp by remember { mutableStateOf(0L) } + // URI Π²Ρ‹Π±Ρ€Π°Π½Π½ΠΎΠ³ΠΎ изобраТСния (Π΄ΠΎ crop) var selectedImageUri by remember { mutableStateOf(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 + isDragging = false + + // Overscroll snap (Π°Π²Π°Ρ‚Π°Ρ€ΠΊΠ° pull-down) + val velocityThreshold = 1000f + when { + overscrollOffset > snapThreshold || (lastVelocity > velocityThreshold && overscrollOffset > snapThreshold * 0.5f) -> { + isPulledDown = true + } + lastVelocity < -velocityThreshold && overscrollOffset > 0 -> { + isPulledDown = false + } + else -> { + 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 { - isDragging = false - - // Telegram: snap Π»ΠΎΠ³ΠΈΠΊΠ° с ΡƒΡ‡Ρ‘Ρ‚ΠΎΠΌ velocity - // Если velocity > 1000 ΠΈ тянСм Π²Π½ΠΈΠ· - snap to expanded Π΄Π°ΠΆΠ΅ Ссли < 33% - // Если velocity < -1000 ΠΈ тянСм Π²Π²Π΅Ρ€Ρ… - snap to collapsed Π΄Π°ΠΆΠ΅ Ссли > 33% - 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 - } - } - 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 + // οΏ½ 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) + ) + } + } + + // ═══════════════════════════════════════════════════════════ + // οΏ½πŸ”™ 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) + ) + } + } + } + } +} diff --git a/gradle.properties b/gradle.properties index 9c44b4b..c1cbad2 100644 --- a/gradle.properties +++ b/gradle.properties @@ -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