diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/metaball/ProfileMetaballOverlay.kt b/app/src/main/java/com/rosetta/messenger/ui/components/metaball/ProfileMetaballOverlay.kt index f2bf4de..8d78ce5 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/components/metaball/ProfileMetaballOverlay.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/components/metaball/ProfileMetaballOverlay.kt @@ -1,6 +1,7 @@ package com.rosetta.messenger.ui.components.metaball import android.graphics.Path +import android.util.Log import android.graphics.RenderEffect import android.graphics.RuntimeShader import android.graphics.Shader @@ -33,6 +34,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import org.intellij.lang.annotations.Language import kotlin.math.abs +import kotlin.math.max import kotlin.math.min import kotlin.math.sqrt import androidx.compose.ui.graphics.Color as ComposeColor @@ -40,12 +42,43 @@ import androidx.compose.ui.graphics.Color as ComposeColor /** * Constants for the Profile Metaball Animation * Based on Telegram's ProfileMetaballView implementation + * + * THRESHOLDS from Telegram: + * - isDrawing: vr <= 40dp (start metaball effect) + * - isNear: vr <= 32dp (avatar very close to notch) + * - Form transition: 40dp → 34dp (circle → rounded rect) + * - Alpha fade: 32dp → 18dp (1.0 → 0.0) + * + * SCALE from Telegram: + * - Collapsed (into notch): avatarScale = lerp(24dp, 96dp, diff) / 100f + * - Expanded (pull-down): avatarScale = lerp(96/42, 138/42, expandProgress*3) / 100f * 42 + * + * POSITION from Telegram: + * - avatarY = lerp(endY, startY, diff) + * - endY = notch center or -dp(29) + * - startY = statusBar + actionBarHeight - dp(21) */ object ProfileMetaballConstants { - // Avatar dimensions + // Avatar dimensions (like Telegram: collapsed into 24dp, normal 96dp, expanded 138dp) + val AVATAR_SIZE_COLLAPSED = 24.dp // intoSize when fully collapsed into notch + val AVATAR_SIZE_NORMAL = 96.dp // Normal expanded avatar size + val AVATAR_SIZE_PULLED = 138.dp // When pulled down (isPulledDown) + + // Legacy - for compatibility val AVATAR_SIZE_EXPANDED = 120.dp val AVATAR_SIZE_MIN = 24.dp + // Telegram thresholds (in dp) + val THRESHOLD_DRAWING = 40.dp // isDrawing = vr <= 40dp + val THRESHOLD_NEAR = 32.dp // isNear = vr <= 32dp + val THRESHOLD_SHAPE_START = 40.dp // Start shape transition + val THRESHOLD_SHAPE_END = 34.dp // End shape transition (fully rounded rect) + val THRESHOLD_ALPHA_START = 32.dp // Start alpha fade + val THRESHOLD_ALPHA_END = 18.dp // End alpha fade (fully transparent) + + // Corner radius for rounded rect form (like Telegram's dp(22)) + val ROUNDED_RECT_RADIUS = 22.dp + // Animation thresholds const val MERGE_START_PROGRESS = 0.5f const val MERGE_COMPLETE_PROGRESS = 0.95f @@ -54,8 +87,15 @@ object ProfileMetaballConstants { const val BLUR_RADIUS = 15f const val CUTOFF = 0.5f + // Blur range for avatar (like Telegram: 2 + (1-fraction) * 20) + const val BLUR_MIN = 2f + const val BLUR_MAX = 22f + // Fallback camera size if no notch detected (like status bar rect in Telegram) val FALLBACK_CAMERA_SIZE = 12.dp + + // Y position offset when fully collapsed (Telegram: -dp(29)) + val COLLAPSED_Y_OFFSET = (-29).dp } /** @@ -84,75 +124,181 @@ private const val ProfileMetaballShaderSource = """ /** * State for avatar position and size during animation + * Like Telegram's ProfileMetaballView state variables */ private data class AvatarState( val centerX: Float, val centerY: Float, - val radius: Float, - val opacity: Float, - val showBlob: Boolean + val radius: Float, // vr in Telegram + val opacity: Float, // alpha / 255f + val showBlob: Boolean, + val isDrawing: Boolean, // vr <= 40dp + val isNear: Boolean, // vr <= 32dp + val cornerRadius: Float, // For rounded rect transition + val blurRadius: Float // Blur amount when near ) /** * Compute avatar state based on collapse progress + * Exact logic from Telegram's ProfileMetaballView.onDraw() and needLayout() + * + * Key formulas from Telegram: + * - avatarScale = lerp(intoSize, 96, diff) / 100f (where intoSize = 24 or notch width/2) + * - avatarY = lerp(endY, startY, diff) (endY = notch center, startY = statusBar + actionBar - dp(21)) + * - vr = view.getWidth() * view.getScaleX() * 0.5f (half of scaled avatar width) */ private fun computeAvatarState( - collapseProgress: Float, - expansionProgress: Float, + collapseProgress: Float, // 0 = expanded, 1 = collapsed into notch + expansionProgress: Float, // 0 = normal, 1 = pulled down to full screen screenWidthPx: Float, statusBarHeightPx: Float, headerHeightPx: Float, - avatarSizeExpandedPx: Float, - avatarSizeMinPx: Float, - hasAvatar: Boolean + avatarSizeExpandedPx: Float, // Normal avatar size (96dp in Telegram terms) + avatarSizeMinPx: Float, // Into notch size (24dp or notch width) + hasAvatar: Boolean, + // Notch info + notchCenterY: Float, + notchRadiusPx: Float, + // Telegram thresholds in pixels + dp40: Float, + dp34: Float, + dp32: Float, + dp18: Float, + dp22: Float, // Corner radius for rounded rect + fullCornerRadius: Float // Full circle corner radius ): AvatarState { - val sharpExpansion = sqrt(expansionProgress.toDouble()).toFloat() + // ═══════════════════════════════════════════════════════════════ + // TELEGRAM LOGIC: diff = 1 - collapseProgress (for us) + // diff = how "expanded" the avatar is (1 = fully expanded, 0 = collapsed) + // ═══════════════════════════════════════════════════════════════ + val diff = 1f - collapseProgress - // Calculate radius + // ═══════════════════════════════════════════════════════════════ + // RADIUS (vr in Telegram) + // Telegram: avatarScale = lerp(intoSize, 96, diff) / 100f + // Then: vr = view.getWidth() * avatarScale * 0.5f + // For us: view.getWidth() = avatarSizeExpandedPx (base size) + // ═══════════════════════════════════════════════════════════════ val radius: Float = when { - hasAvatar && collapseProgress < 0.1f && expansionProgress > 0f -> { - // Expanding to full screen - val expandedRadius = screenWidthPx / 2f - lerpFloat(avatarSizeExpandedPx / 2f, expandedRadius, sharpExpansion) + // Pull-down expansion (like Telegram isPulledDown) + hasAvatar && expansionProgress > 0f -> { + // Telegram: avatarScale = lerp(96/42, 138/42, min(1, expandProgress*3)) / 100f * 42f + val expandScale = lerpFloat( + avatarSizeExpandedPx / 2f, // Normal radius + screenWidthPx / 2f, // Full screen width / 2 + expansionProgress + ) + expandScale } + // Collapsing into notch collapseProgress > 0f -> { - // Collapsing - shrink avatar - val shrinkProgress = (collapseProgress / ProfileMetaballConstants.MERGE_COMPLETE_PROGRESS).coerceIn(0f, 1f) - lerpFloat(avatarSizeExpandedPx / 2f, avatarSizeMinPx / 2f, shrinkProgress) + // Telegram: avatarScale = lerp(intoSize, 96, diff) / 100f + // vr = baseWidth * avatarScale * 0.5 + val intoSize = notchRadiusPx * 2f // Target size = notch diameter + val normalSize = avatarSizeExpandedPx + val avatarWidth = lerpFloat(intoSize, normalSize, diff) + avatarWidth / 2f // radius = half of width } + // Normal state else -> avatarSizeExpandedPx / 2f } - // Center X is always screen center + // ═══════════════════════════════════════════════════════════════ + // Telegram thresholds + // ═══════════════════════════════════════════════════════════════ + val isDrawing = radius <= dp40 + val isNear = radius <= dp32 + + // ═══════════════════════════════════════════════════════════════ + // CENTER X - always screen center + // ═══════════════════════════════════════════════════════════════ val centerX = screenWidthPx / 2f - // Calculate Y position - val defaultCenterY = statusBarHeightPx + (headerHeightPx - statusBarHeightPx) / 2f + 20f - val targetY = statusBarHeightPx / 2f // Target: center of status bar (camera area) + // ═══════════════════════════════════════════════════════════════ + // CENTER Y - Telegram: avatarY = lerp(endY, startY, diff) + // endY = notch center (or -dp(29) if no notch) + // startY = statusBarHeight + actionBarHeight - dp(21) + some offset + // ═══════════════════════════════════════════════════════════════ + val startY = statusBarHeightPx + (headerHeightPx - statusBarHeightPx) / 2f + 20f // Normal center + val endY = notchCenterY // Target = notch center val centerY: Float = when { - hasAvatar && collapseProgress < 0.1f && expansionProgress > 0f -> { - lerpFloat(defaultCenterY, headerHeightPx / 2f, sharpExpansion) + // Pull-down expansion + hasAvatar && expansionProgress > 0f -> { + lerpFloat(startY, headerHeightPx / 2f, expansionProgress) } + // Collapsing - animate Y towards notch else -> { - lerpFloat(defaultCenterY, targetY, collapseProgress) + lerpFloat(endY, startY, diff) } } - // Opacity - val opacity: Float = when { - collapseProgress < ProfileMetaballConstants.MERGE_START_PROGRESS -> 1f - collapseProgress >= ProfileMetaballConstants.MERGE_COMPLETE_PROGRESS -> 0f - else -> { - val mergeProgress = (collapseProgress - ProfileMetaballConstants.MERGE_START_PROGRESS) / - (ProfileMetaballConstants.MERGE_COMPLETE_PROGRESS - ProfileMetaballConstants.MERGE_START_PROGRESS) - (1f - mergeProgress).coerceIn(0f, 1f) + // ═══════════════════════════════════════════════════════════════ + // CORNER RADIUS - Shape transition + // Telegram: аватарка остаётся КРУГЛОЙ пока тянешь! + // Квадратной становится только при полном раскрытии (isPulledDown) + // Переход круг→квадрат начинается при expansionProgress > 0.8 + // ═══════════════════════════════════════════════════════════════ + val cornerRadius: Float = when { + // EXPANDED - переход к квадрату начинается только после 80% expansion + expansionProgress > 0f -> { + // Telegram: квадрат только при полном раскрытии + // До 80% - остаётся круглым, после 80% - быстро переходит в квадрат + val squareProgress = ((expansionProgress - 0.8f) / 0.2f).coerceIn(0f, 1f) + lerpFloat(fullCornerRadius, 0f, squareProgress) } + // COLLAPSING - переход круг → rounded rect (like Telegram) + isDrawing -> { + // Telegram: lerp(dp(22), radius, clamp01((vr - dp(34)) / dp(6))) + val shapeProgress = ((radius - dp34) / (dp40 - dp34)).coerceIn(0f, 1f) + val roundRadiusCollapse = lerpFloat(dp22, fullCornerRadius, shapeProgress) + // Telegram: scaledRadius = vr / dp(22) * roundRadiusCollapse + (radius / dp22) * roundRadiusCollapse + } + // NORMAL - полный круг + else -> fullCornerRadius + } + + // ═══════════════════════════════════════════════════════════════ + // OPACITY - Telegram: alpha changes from 1 to 0 when vr 32dp→18dp + // float fraction = Math.max(0f, vr - dp(18)) / dp(32 - 18); + // float alphaFraction = lerp(0f, 1f, fraction); + // ═══════════════════════════════════════════════════════════════ + val opacity: Float = if (isNear) { + val fraction = max(0f, radius - dp18) / (dp32 - dp18) + lerpFloat(0f, 1f, fraction) + } else { + 1f + } + + // ═══════════════════════════════════════════════════════════════ + // BLUR RADIUS - Telegram: 2 + (1 - fraction) * 20 + // More blur as avatar gets closer to notch + // ═══════════════════════════════════════════════════════════════ + val blurRadius: Float = if (isNear) { + val fraction = max(0f, radius - dp18) / (dp32 - dp18) + ProfileMetaballConstants.BLUR_MIN + (1f - fraction) * (ProfileMetaballConstants.BLUR_MAX - ProfileMetaballConstants.BLUR_MIN) + } else { + 0f } val showBlob = collapseProgress < ProfileMetaballConstants.MERGE_COMPLETE_PROGRESS && radius > 1f - return AvatarState(centerX, centerY, radius, opacity, showBlob) + // DEBUG LOG + Log.d("Metaball", "collapse=$collapseProgress, expansion=$expansionProgress, radius=$radius, diff=$diff") + Log.d("Metaball", "centerY=$centerY, cornerRadius=$cornerRadius, isDrawing=$isDrawing, isNear=$isNear") + + return AvatarState( + centerX = centerX, + centerY = centerY, + radius = radius, + opacity = opacity, + showBlob = showBlob, + isDrawing = isDrawing, + isNear = isNear, + cornerRadius = cornerRadius, + blurRadius = blurRadius + ) } private fun lerpFloat(start: Float, stop: Float, fraction: Float): Float { @@ -226,8 +372,15 @@ fun ProfileMetaballOverlay( } } - // Avatar state - val avatarState by remember(collapseProgress, expansionProgress, screenWidthPx, statusBarHeightPx, headerHeightPx, hasAvatar) { + // Telegram thresholds in pixels + val dp40 = with(density) { ProfileMetaballConstants.THRESHOLD_DRAWING.toPx() } + val dp34 = with(density) { ProfileMetaballConstants.THRESHOLD_SHAPE_END.toPx() } + val dp32 = with(density) { ProfileMetaballConstants.THRESHOLD_NEAR.toPx() } + val dp18 = with(density) { ProfileMetaballConstants.THRESHOLD_ALPHA_END.toPx() } + val dp22 = with(density) { ProfileMetaballConstants.ROUNDED_RECT_RADIUS.toPx() } + + // Avatar state - computed with Telegram's exact logic + val avatarState by remember(collapseProgress, expansionProgress, screenWidthPx, statusBarHeightPx, headerHeightPx, hasAvatar, notchCenterY, notchRadiusPx) { derivedStateOf { computeAvatarState( collapseProgress = collapseProgress, @@ -237,7 +390,15 @@ fun ProfileMetaballOverlay( headerHeightPx = headerHeightPx, avatarSizeExpandedPx = avatarSizeExpandedPx, avatarSizeMinPx = avatarSizeMinPx, - hasAvatar = hasAvatar + hasAvatar = hasAvatar, + notchCenterY = notchCenterY, + notchRadiusPx = notchRadiusPx, + dp40 = dp40, + dp34 = dp34, + dp32 = dp32, + dp18 = dp18, + dp22 = dp22, + fullCornerRadius = avatarSizeExpandedPx / 2f // Full circle = radius ) } } @@ -255,95 +416,101 @@ fun ProfileMetaballOverlay( val distance = avatarState.centerY - notchCenterY val maxDist = avatarSizeExpandedPx val c = (distance / maxDist).coerceIn(-1f, 1f) - val v = ((1f - c / 1.3f) / 2f).coerceIn(0f, 0.8f) - // Like Telegram: isDrawing = vr <= dp(40), isNear = vr <= dp(32) - // Avatar radius thresholds (in px) - val dp40 = with(density) { 40.dp.toPx() } - val dp32 = with(density) { 32.dp.toPx() } - - // Should we draw the metaball effect? (when avatar is small enough) - val isDrawing = avatarState.radius <= dp40 - val isNear = avatarState.radius <= dp32 - - // Show connector only when avatar is close enough AND small enough - val showConnector = isDrawing && avatarState.showBlob && distance < maxDist * 1.5f - - // Calculate blur intensity for avatar based on distance to notch (like Telegram) - // When avatar is far - no blur, when close - more blur - val avatarBlurRadius = if (showConnector) { - // More blur as avatar gets closer to notch - val nearProgress = (1f - (distance / maxDist).coerceIn(0f, 1f)) - (nearProgress * ProfileMetaballConstants.BLUR_RADIUS * 0.5f).coerceIn(0f, 10f) + // Like Telegram: when NOT near, reduce v + // float near = isNear ? 1f : 1f - (vr - dp(32)) / dp(40 - 32); + // v = Math.min(lerp(0f, 0.2f, near), v); + val baseV = ((1f - c / 1.3f) / 2f).coerceIn(0f, 0.8f) + val near = if (avatarState.isNear) 1f else 1f - (avatarState.radius - dp32) / (dp40 - dp32) + val v = if (!avatarState.isNear) { + min(lerpFloat(0f, 0.2f, near), baseV) } else { - 0f + baseV } + // Show connector only when avatar is small enough (like Telegram isDrawing) + // AND not when expanding (no metaball effect when expanded) + val showConnector = expansionProgress == 0f && avatarState.isDrawing && avatarState.showBlob && distance < maxDist * 1.5f + + // Don't show black metaball shapes when expanded + val showMetaballLayer = expansionProgress == 0f + Box(modifier = modifier.fillMaxSize()) { // LAYER 1: Metaball shapes with blur effect (BLACK shapes only) - Canvas( - modifier = Modifier - .fillMaxSize() - .graphicsLayer { - metaShader.setFloatUniform("cutoff", ProfileMetaballConstants.CUTOFF) - // IMPORTANT: First blur, THEN threshold shader - // createChainEffect(outer, inner) - inner is applied first - val blurEffect = RenderEffect.createBlurEffect( - ProfileMetaballConstants.BLUR_RADIUS, - ProfileMetaballConstants.BLUR_RADIUS, - Shader.TileMode.DECAL - ) - val thresholdEffect = RenderEffect.createRuntimeShaderEffect(metaShader, "composable") - // Chain: blur first (inner), then threshold (outer) - renderEffect = RenderEffect.createChainEffect(thresholdEffect, blurEffect) - .asComposeRenderEffect() - } - ) { - val paint = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply { - color = android.graphics.Color.BLACK - style = android.graphics.Paint.Style.FILL - } - - drawIntoCanvas { canvas -> - val nativeCanvas = canvas.nativeCanvas - - // Draw notch/camera circle (small circle at top) - if (showConnector) { - if (notchInfo != null && notchInfo.isLikelyCircle) { - // Draw circle at actual notch position - nativeCanvas.drawCircle( - notchCenterX, - notchCenterY, - notchRadiusPx, - paint + // HIDDEN when expanded - only show avatar content + if (showMetaballLayer) { + Canvas( + modifier = Modifier + .fillMaxSize() + .graphicsLayer { + metaShader.setFloatUniform("cutoff", ProfileMetaballConstants.CUTOFF) + // IMPORTANT: First blur, THEN threshold shader + // createChainEffect(outer, inner) - inner is applied first + val blurEffect = RenderEffect.createBlurEffect( + ProfileMetaballConstants.BLUR_RADIUS, + ProfileMetaballConstants.BLUR_RADIUS, + Shader.TileMode.DECAL ) - } else if (notchInfo != null && notchInfo.isAccurate && notchInfo.path != null) { - // Draw actual notch path - nativeCanvas.drawPath(notchInfo.path, paint) - } else if (notchInfo != null) { - // Draw rounded rect for non-accurate notch - val bounds = notchInfo.bounds - val rad = kotlin.math.max(bounds.width(), bounds.height()) / 2f - nativeCanvas.drawRoundRect(bounds, rad, rad, paint) - } else { - // No notch - draw small circle at status bar center - nativeCanvas.drawCircle( - screenWidthPx / 2f, - statusBarHeightPx / 2f, - notchRadiusPx, + val thresholdEffect = RenderEffect.createRuntimeShaderEffect(metaShader, "composable") + // Chain: blur first (inner), then threshold (outer) + renderEffect = RenderEffect.createChainEffect(thresholdEffect, blurEffect) + .asComposeRenderEffect() + } + ) { + val paint = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply { + color = android.graphics.Color.BLACK + style = android.graphics.Paint.Style.FILL + } + + drawIntoCanvas { canvas -> + val nativeCanvas = canvas.nativeCanvas + + // Draw notch/camera circle (small circle at top) + if (showConnector) { + if (notchInfo != null && notchInfo.isLikelyCircle) { + // Draw circle at actual notch position + nativeCanvas.drawCircle( + notchCenterX, + notchCenterY, + notchRadiusPx, + paint + ) + } else if (notchInfo != null && notchInfo.isAccurate && notchInfo.path != null) { + // Draw actual notch path + nativeCanvas.drawPath(notchInfo.path, paint) + } else if (notchInfo != null) { + // Draw rounded rect for non-accurate notch + val bounds = notchInfo.bounds + val rad = kotlin.math.max(bounds.width(), bounds.height()) / 2f + nativeCanvas.drawRoundRect(bounds, rad, rad, paint) + } else { + // No notch - draw small circle at status bar center + nativeCanvas.drawCircle( + screenWidthPx / 2f, + statusBarHeightPx / 2f, + notchRadiusPx, paint ) } } - // Draw avatar circle + // Draw avatar shape - circle or rounded rect depending on state if (avatarState.showBlob) { - nativeCanvas.drawCircle( - avatarState.centerX, - avatarState.centerY, - avatarState.radius, - paint - ) + val cx = avatarState.centerX + val cy = avatarState.centerY + val r = avatarState.radius + val cornerR = avatarState.cornerRadius + + // If cornerRadius is close to radius, draw circle; otherwise rounded rect + if (cornerR >= r * 0.95f) { + // Draw circle + nativeCanvas.drawCircle(cx, cy, r, paint) + } else { + // Draw rounded rect (like Telegram when isDrawing) + android.graphics.RectF(cx - r, cy - r, cx + r, cy + r).let { rect -> + nativeCanvas.drawRoundRect(rect, cornerR, cornerR, paint) + } + } } // Draw metaball connector path @@ -359,26 +526,43 @@ fun ProfileMetaballOverlay( } } } + } // END if (showMetaballLayer) - // LAYER 2: Actual avatar content - with blur when close to notch + // LAYER 2: Actual avatar content - with blur and shape transition like Telegram if (avatarState.showBlob) { - val avatarSizeDp = with(density) { (avatarState.radius * 2f).toDp() } - val avatarOffsetX = with(density) { (avatarState.centerX - avatarState.radius).toDp() } - val avatarOffsetY = with(density) { (avatarState.centerY - avatarState.radius).toDp() } + // При раскрытии - занимаем всю ширину экрана и всю высоту header'а + val isExpanded = expansionProgress > 0f + val avatarWidthDp = with(density) { + if (isExpanded) screenWidth else (avatarState.radius * 2f).toDp() + } + val avatarHeightDp = with(density) { + if (isExpanded) headerHeight else (avatarState.radius * 2f).toDp() + } + val avatarOffsetX = with(density) { + if (isExpanded) 0.dp else (avatarState.centerX - avatarState.radius).toDp() + } + val avatarOffsetY = with(density) { + if (isExpanded) 0.dp else (avatarState.centerY - avatarState.radius).toDp() + } + + // Corner radius from state - transitions from circle to rounded rect + val cornerRadiusDp = with(density) { avatarState.cornerRadius.toDp() } Box( modifier = Modifier .offset(x = avatarOffsetX, y = avatarOffsetY) - .width(avatarSizeDp) - .height(avatarSizeDp) - .clip(RoundedCornerShape(avatarSizeDp / 2)) + .width(avatarWidthDp) + .height(avatarHeightDp) + // Use dynamic corner radius - circle → rounded rect transition + .clip(RoundedCornerShape(cornerRadiusDp)) .graphicsLayer { alpha = avatarState.opacity - // Apply blur to avatar when close to notch (like Telegram) - if (avatarBlurRadius > 0.5f) { + // Apply blur to avatar when near notch (like Telegram) + // Telegram: blurRadius = 2 + (1 - fraction) * 20 + if (avatarState.blurRadius > 0.5f) { renderEffect = RenderEffect.createBlurEffect( - avatarBlurRadius, - avatarBlurRadius, + avatarState.blurRadius, + avatarState.blurRadius, Shader.TileMode.DECAL ).asComposeRenderEffect() } @@ -392,6 +576,7 @@ fun ProfileMetaballOverlay( /** * Compat version for older Android - simple animation without metaball effect + * Still includes shape transition (circle → rounded rect) but no blur/gooey effects */ @Composable fun ProfileMetaballOverlayCompat( @@ -414,6 +599,17 @@ fun ProfileMetaballOverlayCompat( val avatarSizeExpandedPx = with(density) { ProfileMetaballConstants.AVATAR_SIZE_EXPANDED.toPx() } val avatarSizeMinPx = with(density) { ProfileMetaballConstants.AVATAR_SIZE_MIN.toPx() } + // Fallback notch values for compat mode + val notchCenterY = statusBarHeightPx / 2f + val notchRadiusPx = with(density) { ProfileMetaballConstants.FALLBACK_CAMERA_SIZE.toPx() } + + // Telegram thresholds in pixels + val dp40 = with(density) { ProfileMetaballConstants.THRESHOLD_DRAWING.toPx() } + val dp34 = with(density) { ProfileMetaballConstants.THRESHOLD_SHAPE_END.toPx() } + val dp32 = with(density) { ProfileMetaballConstants.THRESHOLD_NEAR.toPx() } + val dp18 = with(density) { ProfileMetaballConstants.THRESHOLD_ALPHA_END.toPx() } + val dp22 = with(density) { ProfileMetaballConstants.ROUNDED_RECT_RADIUS.toPx() } + val avatarState by remember(collapseProgress, expansionProgress, screenWidthPx, statusBarHeightPx, headerHeightPx, hasAvatar) { derivedStateOf { computeAvatarState( @@ -424,23 +620,43 @@ fun ProfileMetaballOverlayCompat( headerHeightPx = headerHeightPx, avatarSizeExpandedPx = avatarSizeExpandedPx, avatarSizeMinPx = avatarSizeMinPx, - hasAvatar = hasAvatar + hasAvatar = hasAvatar, + notchCenterY = notchCenterY, + notchRadiusPx = notchRadiusPx, + dp40 = dp40, + dp34 = dp34, + dp32 = dp32, + dp18 = dp18, + dp22 = dp22, + fullCornerRadius = avatarSizeExpandedPx / 2f ) } } Box(modifier = modifier.fillMaxSize()) { if (avatarState.showBlob) { - val avatarSizeDp = with(density) { (avatarState.radius * 2f).toDp() } - val avatarOffsetX = with(density) { (avatarState.centerX - avatarState.radius).toDp() } - val avatarOffsetY = with(density) { (avatarState.centerY - avatarState.radius).toDp() } + // При раскрытии - занимаем всю ширину экрана и всю высоту header'а + val isExpanded = expansionProgress > 0f + val avatarWidthDp = with(density) { + if (isExpanded) screenWidth else (avatarState.radius * 2f).toDp() + } + val avatarHeightDp = with(density) { + if (isExpanded) headerHeight else (avatarState.radius * 2f).toDp() + } + val avatarOffsetX = with(density) { + if (isExpanded) 0.dp else (avatarState.centerX - avatarState.radius).toDp() + } + val avatarOffsetY = with(density) { + if (isExpanded) 0.dp else (avatarState.centerY - avatarState.radius).toDp() + } + val cornerRadiusDp = with(density) { avatarState.cornerRadius.toDp() } Box( modifier = Modifier .offset(x = avatarOffsetX, y = avatarOffsetY) - .width(avatarSizeDp) - .height(avatarSizeDp) - .clip(RoundedCornerShape(avatarSizeDp / 2)) + .width(avatarWidthDp) + .height(avatarHeightDp) + .clip(RoundedCornerShape(cornerRadiusDp)) .graphicsLayer { alpha = avatarState.opacity }, 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 a166d6a..9e082e2 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 @@ -7,6 +7,7 @@ import android.net.Uri import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import android.util.Log import androidx.compose.animation.* import androidx.compose.animation.core.* import androidx.compose.foundation.* @@ -290,12 +291,13 @@ fun ProfileScreen( val maxScrollOffset = expandedHeightPx - collapsedHeightPx // Track scroll offset for collapsing (скролл вверх = collapse) + // Telegram: extraHeight - напрямую от позиции первого элемента var scrollOffset by remember { mutableFloatStateOf(0f) } // Calculate collapse progress (0 = expanded, 1 = collapsed) - val collapseProgress by remember { - derivedStateOf { (scrollOffset / maxScrollOffset).coerceIn(0f, 1f) } - } + // Telegram: diff = (extraHeight - actionsHeight) / headerOnlyExtraHeight + // Напрямую без derivedStateOf для плавности + val collapseProgress = (scrollOffset / maxScrollOffset).coerceIn(0f, 1f) // Dynamic header height based on scroll val headerHeight = @@ -304,40 +306,46 @@ fun ProfileScreen( } // Track overscroll offset for avatar expansion (скролл вниз при достижении верха) + // Telegram: когда extraHeight > headerExtraHeight - это isPulledDown var overscrollOffset by remember { mutableFloatStateOf(0f) } val maxOverscroll = with(density) { 200.dp.toPx() } - val snapThreshold = maxOverscroll * 0.5f + val snapThreshold = maxOverscroll * 0.33f // Telegram: expandProgress >= 0.33f // Track if user is currently dragging var isDragging by remember { mutableStateOf(false) } + + // Track if fully expanded (snapped to full) + var isFullyExpanded by remember { mutableStateOf(false) } - // Целевое значение для snap: либо 0 (круг), либо maxOverscroll (квадрат) - val snapTarget by remember { - derivedStateOf { - if (!isDragging && overscrollOffset > snapThreshold) maxOverscroll - else if (!isDragging) 0f else overscrollOffset - } + // Smooth snap animation - только когда отпустили палец + // Telegram использует CubicBezierInterpolator.EASE_BOTH для snap + val targetOverscroll = when { + isDragging -> overscrollOffset // Во время drag - напрямую + isFullyExpanded -> maxOverscroll // Зафиксировано в раскрытом + overscrollOffset > snapThreshold -> maxOverscroll // Snap к раскрытому + else -> 0f // Snap к закрытому } - - // Быстрая snap-анимация без застревания - val animatedOverscroll by - animateFloatAsState( - targetValue = snapTarget, - animationSpec = - spring( - dampingRatio = Spring.DampingRatioMediumBouncy, // Меньше пружинистости - stiffness = 4000f // Очень быстрый snap - ), - label = "overscroll" - ) + + val animatedOverscroll by animateFloatAsState( + targetValue = targetOverscroll, + animationSpec = tween( + durationMillis = if (isDragging) 0 else 250, // Мгновенно при drag, плавно при snap + easing = FastOutSlowInEasing // Как Telegram's EASE_BOTH + ), + label = "overscroll" + ) // Calculate expansion progress (0 = круг, 1 = квадрат) - только когда header раскрыт - val expansionProgress by remember { - derivedStateOf { - if (collapseProgress > 0.1f) 0f // Не расширяем если header коллапсирован - else (animatedOverscroll / maxOverscroll).coerceIn(0f, 1f) - } + // Telegram: expandProgress = (extraHeight - headerExtraHeight) / (listWidth - actionBarHeight - headerOnlyExtraHeight) + val expansionProgress = when { + collapseProgress > 0.1f -> 0f // Не расширяем если header коллапсирован + isDragging -> (overscrollOffset / maxOverscroll).coerceIn(0f, 1f) // Напрямую при drag + else -> (animatedOverscroll / maxOverscroll).coerceIn(0f, 1f) // Анимированно при snap } + + // DEBUG LOGS + Log.d("ProfileScroll", "scrollOffset=$scrollOffset, collapseProgress=$collapseProgress") + Log.d("ProfileScroll", "overscrollOffset=$overscrollOffset, expansionProgress=$expansionProgress, isDragging=$isDragging") // Nested scroll connection для collapsing + overscroll val nestedScrollConnection = remember { @@ -353,6 +361,10 @@ fun ProfileScreen( val newOffset = (overscrollOffset + delta).coerceAtLeast(0f) val consumed = overscrollOffset - newOffset overscrollOffset = newOffset + // Если вышли из fully expanded - сбросить флаг + if (overscrollOffset < maxOverscroll * 0.9f) { + isFullyExpanded = false + } return Offset(0f, -consumed) } // Затем коллапсируем header @@ -382,7 +394,8 @@ fun ProfileScreen( ): Offset { // Если достигли верха (scrollOffset = 0) и тянем вниз - создаем overscroll if (available.y > 0 && scrollOffset == 0f) { - val resistance = 0.5f // Легче тянуть (было 0.3f) + // Telegram: if (!isPulledDown) dy /= 2 + val resistance = if (isFullyExpanded) 1f else 0.5f val delta = available.y * resistance overscrollOffset = (overscrollOffset + delta).coerceAtMost(maxOverscroll) return Offset(0f, available.y) @@ -392,6 +405,10 @@ fun ProfileScreen( override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { isDragging = false + // Если перешли порог - зафиксировать как fully expanded + if (overscrollOffset > snapThreshold) { + isFullyExpanded = true + } return Velocity.Zero } }