diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/metaball/MetaballEffect.kt b/app/src/main/java/com/rosetta/messenger/ui/components/metaball/MetaballEffect.kt index bee2c2c..3f45fa9 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/components/metaball/MetaballEffect.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/components/metaball/MetaballEffect.kt @@ -69,7 +69,7 @@ fun Modifier.customBlur(blur: Float) = this.then( .createBlurEffect( blur, blur, - Shader.TileMode.DECAL, + Shader.TileMode.CLAMP, ) .asComposeRenderEffect() } 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 7a3b1a4..c5e6fe8 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 @@ -2,44 +2,31 @@ package com.rosetta.messenger.ui.components.metaball import android.graphics.ColorMatrixColorFilter import android.graphics.Path +import android.graphics.PorterDuff +import android.graphics.PorterDuffXfermode import android.graphics.RectF -import android.util.Log import android.graphics.RenderEffect import android.graphics.Shader import android.os.Build import android.view.Gravity import androidx.annotation.RequiresApi import androidx.compose.foundation.Canvas -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Switch -import androidx.compose.material3.SwitchDefaults -import androidx.compose.material3.Text -import androidx.compose.ui.graphics.RectangleShape import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.asComposeRenderEffect import androidx.compose.ui.graphics.drawscope.drawIntoCanvas import androidx.compose.ui.graphics.graphicsLayer @@ -47,308 +34,194 @@ import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import kotlin.math.abs +import kotlin.math.PI +import kotlin.math.cos import kotlin.math.max import kotlin.math.min -import kotlin.math.sqrt 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) - */ +// ═══════════════════════════════════════════════════════════════════════ +// Constants — exact match of Telegram's ProfileGooeyView +// ═══════════════════════════════════════════════════════════════════════ + object ProfileMetaballConstants { - // 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 - - // Standard ActionBar height (like Telegram's actionBar.getHeight()) - val ACTION_BAR_HEIGHT = 56.dp - - // Offset from actionBar bottom to avatar center (Telegram: -dp(21)) - val AVATAR_Y_OFFSET = 21.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 - - // Blur settings for gooey effect (like Telegram's intensity = 15f) + val AVATAR_SIZE_COLLAPSED = 24.dp + val AVATAR_SIZE_NORMAL = 96.dp + val AVATAR_SIZE_PULLED = 138.dp + val AVATAR_SIZE_EXPANDED = 120.dp + val AVATAR_SIZE_MIN = 24.dp + val ACTION_BAR_HEIGHT = 56.dp + val AVATAR_Y_OFFSET = 21.dp + val THRESHOLD_DRAWING = 40.dp + val THRESHOLD_NEAR = 32.dp + val THRESHOLD_SHAPE_START = 40.dp + val THRESHOLD_SHAPE_END = 34.dp + val THRESHOLD_ALPHA_START = 32.dp + val THRESHOLD_ALPHA_END = 10.dp // extended from 18dp for smoother fade-out + val ROUNDED_RECT_RADIUS = 22.dp + const val MERGE_START_PROGRESS = 0.5f + const val MERGE_COMPLETE_PROGRESS = 0.99f // let opacity handle fade, don't cut off early + /** Telegram: setIntensity(15f) — base blur kernel size */ const val BLUR_RADIUS = 15f - - // 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) + const val BLUR_MIN = 2f + const val BLUR_MAX = 22f val FALLBACK_CAMERA_SIZE = 12.dp - - // Y position offset when fully collapsed (Telegram: -dp(29)) - val COLLAPSED_Y_OFFSET = (-29).dp + val COLLAPSED_Y_OFFSET = (-29).dp } +// ═══════════════════════════════════════════════════════════════════════ +// Alpha threshold color matrices — the core gooey trick +// ═══════════════════════════════════════════════════════════════════════ + /** - * GPU alpha threshold via ColorMatrixColorFilter (API 31+) - * Replaces AGSL RuntimeShader — same gooey effect, works on Android 12+ - * - * From Telegram's ProfileGooeyView.GPUImpl: - * Alpha row: newAlpha = oldAlpha * 51 - 6375 - * Values above ~125/255 become opaque, below become transparent. - * Equivalent to AGSL cutoff=0.5 but without requiring API 33. + * GPU path: preserves RGB, thresholds alpha. + * newAlpha = oldAlpha × 51 − 6375 → cutoff at ~125/255 (≈49 %). + * Exact match of Telegram's GPUImpl filter. */ private val GPU_ALPHA_THRESHOLD_MATRIX = floatArrayOf( - 1f, 0f, 0f, 0f, 0f, - 0f, 1f, 0f, 0f, 0f, - 0f, 0f, 1f, 0f, 0f, + 1f, 0f, 0f, 0f, 0f, + 0f, 1f, 0f, 0f, 0f, + 0f, 0f, 1f, 0f, 0f, 0f, 0f, 0f, 51f, 51f * -125f ) /** - * No-notch fallback: black bar height at the top of the screen. - * Matches Telegram's BLACK_KING_BAR = 32dp. - * When no notch is detected, the avatar merges into this bar instead. + * CPU path: forces RGB → 0 (black mask), steeper threshold. + * newAlpha = oldAlpha × 60 − 7500 → cutoff at ~125/255. + * Exact match of Telegram's CPUImpl filter. */ +private val CPU_ALPHA_THRESHOLD_MATRIX = floatArrayOf( + 0f, 0f, 0f, 0f, 0f, + 0f, 0f, 0f, 0f, 0f, + 0f, 0f, 0f, 0f, 0f, + 0f, 0f, 0f, 60f, -7500f +) + +/** Telegram's BLACK_KING_BAR = 32 dp */ private const val BLACK_BAR_HEIGHT_DP = 32f -/** - * State for avatar position and size during animation - * Like Telegram's ProfileMetaballView state variables - */ +// ═══════════════════════════════════════════════════════════════════════ +// Avatar state computation +// ═══════════════════════════════════════════════════════════════════════ + private data class AvatarState( val centerX: Float, val centerY: Float, - val radius: Float, // vr in Telegram - val opacity: Float, // alpha / 255f + val radius: Float, + val opacity: Float, 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 + val isDrawing: Boolean, + val isNear: Boolean, + val cornerRadius: Float, + /** Progressive content blur (not gooey!) — Telegram's blurIntensity */ + val contentBlurRadius: Float, + /** Telegram's imageAlpha: 1→0 as pullProgress 0.5→1.0 */ + val imageAlpha: Float ) -/** - * 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, // 0 = expanded, 1 = collapsed into notch - expansionProgress: Float, // 0 = normal, 1 = pulled down to full screen + collapseProgress: Float, + expansionProgress: Float, screenWidthPx: Float, statusBarHeightPx: Float, headerHeightPx: Float, - avatarSizeExpandedPx: Float, // Normal avatar size (96dp in Telegram terms) - avatarSizeMinPx: Float, // Into notch size (24dp or notch width) + avatarSizeExpandedPx: Float, + @Suppress("UNUSED_PARAMETER") avatarSizeMinPx: Float, hasAvatar: Boolean, - // Notch info - notchCenterX: Float, // X position of front camera/notch + notchCenterX: Float, 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 + dp40: Float, dp34: Float, dp32: Float, dp18: Float, dp22: Float, + fullCornerRadius: Float ): AvatarState { - // ═══════════════════════════════════════════════════════════════ - // TELEGRAM LOGIC: diff = 1 - collapseProgress (for us) - // diff = how "expanded" the avatar is (1 = fully expanded, 0 = collapsed) - // ═══════════════════════════════════════════════════════════════ val diff = 1f - collapseProgress - - // ═══════════════════════════════════════════════════════════════ - // 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) - // ═══════════════════════════════════════════════════════════════ + + // Radius (vr) val radius: Float = when { - // 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 + hasAvatar && expansionProgress > 0f -> + lerpF(avatarSizeExpandedPx / 2f, screenWidthPx / 2f, expansionProgress) collapseProgress > 0f -> { - // 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 + val intoSize = notchRadiusPx * 2f + lerpF(intoSize, avatarSizeExpandedPx, diff) / 2f } - // Normal state else -> avatarSizeExpandedPx / 2f } - - // ═══════════════════════════════════════════════════════════════ - // Telegram thresholds - // ═══════════════════════════════════════════════════════════════ + val isDrawing = radius <= dp40 - val isNear = radius <= dp32 - - // ═══════════════════════════════════════════════════════════════ - // CENTER X - animate towards notch/camera position when collapsing - // Normal: screen center, Collapsed: notch center (front camera) - // ═══════════════════════════════════════════════════════════════ - val startX = screenWidthPx / 2f // Normal position = screen center - val endX = notchCenterX // Target = front camera position - + val isNear = radius <= dp32 + + // Center X val centerX: Float = when { - // Pull-down expansion - stay at screen center hasAvatar && expansionProgress > 0f -> screenWidthPx / 2f - // Collapsing - animate X towards notch/camera - collapseProgress > 0f -> lerpFloat(endX, startX, diff) - // Normal state - screen center + collapseProgress > 0f -> lerpF(notchCenterX, screenWidthPx / 2f, diff) else -> screenWidthPx / 2f } - - // ═══════════════════════════════════════════════════════════════ - // CENTER Y - Telegram: avatarY = lerp(endY, startY, diff) - // startY = statusBar + actionBarHeight + avatarRadius - dp(21) - // endY = notch center (or -dp(29) if no notch) - // - // Telegram формула: startY = statusBarHeight + actionBarHeight - dp(21) + avatarRadius - // Это помещает центр аватара на dp(21) НИЖЕ нижней границы actionBar - // ═══════════════════════════════════════════════════════════════ - val actionBarHeightPx = dp40 + dp18 // ~58dp, близко к стандартным 56dp - val avatarYOffset = dp22 // ~21dp offset ниже actionBar + + // Center Y + val actionBarHeightPx = dp40 + dp18 // 56 dp in px + val avatarYOffset = dp22 // 21 dp ≈ 22 dp val startY = statusBarHeightPx + actionBarHeightPx - avatarYOffset + (avatarSizeExpandedPx / 2f) - val endY = notchCenterY // Target = notch center - + val endY = notchCenterY val centerY: Float = when { - // Pull-down expansion - hasAvatar && expansionProgress > 0f -> { - lerpFloat(startY, headerHeightPx / 2f, expansionProgress) - } - // Collapsing - animate Y towards notch - else -> { - lerpFloat(endY, startY, diff) - } + hasAvatar && expansionProgress > 0f -> + lerpF(startY, headerHeightPx / 2f, expansionProgress) + else -> lerpF(endY, startY, diff) } - - // ═══════════════════════════════════════════════════════════════ - // CORNER RADIUS - Shape transition - // Telegram: аватарка остаётся КРУГЛОЙ пока тянешь! - // Квадратной становится только при полном раскрытии (isPulledDown) - // Переход круг→квадрат начинается при expansionProgress > 0.8 - // ═══════════════════════════════════════════════════════════════ + + // Corner radius 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) + val squareP = ((expansionProgress - 0.8f) / 0.2f).coerceIn(0f, 1f) + lerpF(fullCornerRadius, 0f, squareP) } - // 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 + val shapeP = ((radius - dp34) / (dp40 - dp34)).coerceIn(0f, 1f) + val roundR = lerpF(dp22, fullCornerRadius, shapeP) + (radius / dp22) * roundR } - // 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 - } - + + // Opacity — smooth alpha fade: 32 dp → 10 dp with ease-out curve + // Wider range + quadratic easing = much smoother disappearance + val opacity = if (isNear) { + val linearFraction = (max(0f, radius - dp18) / (dp32 - dp18)).coerceIn(0f, 1f) + // Ease-out: starts fast, slows down at the end → more time spent visible + val eased = 1f - (1f - linearFraction) * (1f - linearFraction) + lerpF(0f, 1f, eased) + } else 1f + + // Content blur — Telegram: blurIntensity = min((clamp(pull,0.2,0.7)−0.2)/0.5, 0.75) + val contentBlurIntensity = min(((collapseProgress - 0.2f) / 0.5f).coerceIn(0f, 1f), 0.75f) + val contentBlurRadius = contentBlurIntensity * ProfileMetaballConstants.BLUR_RADIUS + + // Image alpha — Telegram: (1 − ilerp(pullProgress, 0.5, 1.0)) × 255 + val imageAlpha = 1f - ((collapseProgress - 0.5f) / 0.5f).coerceIn(0f, 1f) + val showBlob = collapseProgress < ProfileMetaballConstants.MERGE_COMPLETE_PROGRESS && radius > 1f - - // DEBUG LOG + return AvatarState( - centerX = centerX, - centerY = centerY, - radius = radius, - opacity = opacity, - showBlob = showBlob, - isDrawing = isDrawing, - isNear = isNear, + centerX = centerX, centerY = centerY, radius = radius, + opacity = opacity, showBlob = showBlob, + isDrawing = isDrawing, isNear = isNear, cornerRadius = cornerRadius, - blurRadius = blurRadius + contentBlurRadius = contentBlurRadius, + imageAlpha = imageAlpha ) } -private fun lerpFloat(start: Float, stop: Float, fraction: Float): Float { - return start + (stop - start) * fraction -} +private fun lerpF(a: Float, b: Float, t: Float): Float = a + (b - a) * t + +// ═══════════════════════════════════════════════════════════════════════ +// GPU PATH — Android 12+ (API 31) +// +// KEY FIX: Telegram draws shapes at 1/gooScaleFactor into a RenderNode, +// blurs at 15 px, then paints at gooScaleFactor ⇒ effective blur ≈ 45 px. +// We compensate: gooBlurRadius = BLUR_RADIUS × gooScaleFactor (CONSTANT). +// ═══════════════════════════════════════════════════════════════════════ -/** - * Profile Metaball Effect with real Bezier path - like Telegram! - * - * Creates a liquid "droplet" effect when scrolling up - the avatar - * stretches and merges with the camera/notch area using smooth curves. - * - * IMPORTANT: Blur is applied ONLY to the metaball shapes, NOT to the avatar content! - */ @RequiresApi(Build.VERSION_CODES.S) @Composable fun ProfileMetaballOverlay( @@ -366,159 +239,106 @@ fun ProfileMetaballOverlay( val configuration = LocalConfiguration.current val screenWidth = configuration.screenWidthDp.dp val density = LocalDensity.current - - // Debug: log screen dimensions once - val TAG = "ProfileMetaball" - - // Convert to pixels for path calculations - val screenWidthPx = with(density) { screenWidth.toPx() } - val statusBarHeightPx = with(density) { statusBarHeight.toPx() } - val headerHeightPx = with(density) { headerHeight.toPx() } - val avatarSizeExpandedPx = with(density) { ProfileMetaballConstants.AVATAR_SIZE_EXPANDED.toPx() } - val avatarSizeMinPx = with(density) { ProfileMetaballConstants.AVATAR_SIZE_MIN.toPx() } - - // Get REAL camera/notch info from system (like Telegram does) + + val screenWidthPx = with(density) { screenWidth.toPx() } + val statusBarHPx = with(density) { statusBarHeight.toPx() } + val headerHPx = with(density) { headerHeight.toPx() } + val avatarExpandPx = with(density) { ProfileMetaballConstants.AVATAR_SIZE_EXPANDED.toPx() } + val avatarMinPx = with(density) { ProfileMetaballConstants.AVATAR_SIZE_MIN.toPx() } + val blackBarPx = with(density) { BLACK_BAR_HEIGHT_DP.dp.toPx() } + + // ── Notch ── val notchInfo = remember { NotchInfoUtils.getInfo(context) } - - // Debug log notch info once - LaunchedEffect(notchInfo, screenWidthPx, statusBarHeightPx, headerHeightPx) { - Log.d(TAG, "NotchInfo: gravity=${notchInfo?.gravity}, isCircle=${notchInfo?.isLikelyCircle}, bounds=${notchInfo?.bounds}") - Log.d(TAG, "Screen: width=${screenWidthPx}px, statusBar=${statusBarHeightPx}px, header=${headerHeightPx}px") - } - - // Calculate notch radius based on real device notch (like Telegram's ProfileGooeyView) + val hasRealNotch = !MetaballDebug.forceNoNotch && + notchInfo != null && notchInfo.gravity == Gravity.CENTER && notchInfo.bounds.width() > 0 val notchRadiusPx = remember(notchInfo) { - if (notchInfo != null && notchInfo.gravity == Gravity.CENTER) { - if (notchInfo.isLikelyCircle) { - // Circular camera cutout - use actual width/height - min(notchInfo.bounds.width(), notchInfo.bounds.height()) / 2f - } else { - // Non-circular notch - use max dimension - kotlin.math.max(notchInfo.bounds.width(), notchInfo.bounds.height()) / 2f - } - } else { - // No notch info - use fallback (small circle like status bar) - with(density) { ProfileMetaballConstants.FALLBACK_CAMERA_SIZE.toPx() } - } + if (hasRealNotch && notchInfo != null) { + if (notchInfo.isLikelyCircle) min(notchInfo.bounds.width(), notchInfo.bounds.height()) / 2f + else max(notchInfo.bounds.width(), notchInfo.bounds.height()) / 2f + } else with(density) { ProfileMetaballConstants.FALLBACK_CAMERA_SIZE.toPx() } } - - // Notch center position - ONLY use if notch is centered (like front camera) - // If notch is off-center (corner notch), use screen center instead - val notchCenterX = remember(notchInfo, screenWidthPx) { - if (notchInfo != null && notchInfo.gravity == Gravity.CENTER) { - // Centered notch (like Dynamic Island or punch-hole camera) - notchInfo.bounds.centerX() - } else { - // No notch or off-center notch - always use screen center - screenWidthPx / 2f - } + val notchCX = remember(notchInfo, screenWidthPx) { + if (hasRealNotch && notchInfo != null) notchInfo.bounds.centerX() else screenWidthPx / 2f } - - val notchCenterY = remember(notchInfo) { - if (notchInfo != null && notchInfo.isLikelyCircle) { - // For circle: center is at bottom - width/2 (like Telegram) + val notchCY = remember(notchInfo) { + if (hasRealNotch && notchInfo != null && notchInfo.isLikelyCircle) notchInfo.bounds.bottom - notchInfo.bounds.width() / 2f - } else if (notchInfo != null) { - notchInfo.bounds.centerY() - } else { - statusBarHeightPx / 2f - } + else if (hasRealNotch && notchInfo != null) notchInfo.bounds.centerY() + else statusBarHPx / 2f } - - // Telegram thresholds in pixels + + // ── DP helpers ── 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() } - - // 🔥 FIX: Убраны volatile keys (collapseProgress, expansionProgress) из remember - // derivedStateOf автоматически отслеживает их как зависимости внутри лямбды - // Только стабильные параметры (размеры экрана, notch info) как ключи remember - val avatarState by remember(screenWidthPx, statusBarHeightPx, headerHeightPx, notchCenterX, notchCenterY, notchRadiusPx) { + val dp7 = with(density) { 7.dp.toPx() } + + // ── Avatar state ── + val avatarState by remember( + screenWidthPx, statusBarHPx, headerHPx, notchCX, notchCY, notchRadiusPx + ) { derivedStateOf { computeAvatarState( - collapseProgress = collapseProgress, - expansionProgress = expansionProgress, - screenWidthPx = screenWidthPx, - statusBarHeightPx = statusBarHeightPx, - headerHeightPx = headerHeightPx, - avatarSizeExpandedPx = avatarSizeExpandedPx, - avatarSizeMinPx = avatarSizeMinPx, - hasAvatar = hasAvatar, - notchCenterX = notchCenterX, - notchCenterY = notchCenterY, - notchRadiusPx = notchRadiusPx, - dp40 = dp40, - dp34 = dp34, - dp32 = dp32, - dp18 = dp18, - dp22 = dp22, - fullCornerRadius = avatarSizeExpandedPx / 2f // Full circle = radius + collapseProgress, expansionProgress, + screenWidthPx, statusBarHPx, headerHPx, + avatarExpandPx, avatarMinPx, hasAvatar, + notchCX, notchCY, notchRadiusPx, + dp40, dp34, dp32, dp18, dp22, + avatarExpandPx / 2f ) } } - - // Path for metaball connector - val metaballPath = remember { Path() } - val c1 = remember { Point() } // Notch center - val c2 = remember { Point() } // Avatar center - // Reusable RectF like Telegram's AndroidUtilities.rectTmp + // ── Connector ── + val metaballPath = remember { Path() } + val c1 = remember { Point() } + val c2 = remember { Point() } val rectTmp = remember { RectF() } - // Black bar height for no-notch fallback (Telegram's BLACK_KING_BAR) - val blackBarHeightPx = with(density) { BLACK_BAR_HEIGHT_DP.dp.toPx() } + val dist = avatarState.centerY - notchCY + val maxDist = avatarExpandPx + val cParam = (dist / maxDist).coerceIn(-1f, 1f) + val baseV = ((1f - cParam / 1.3f) / 2f).coerceIn(0f, 0.8f) + val nearFrac = if (avatarState.isNear) 1f + else 1f - (avatarState.radius - dp32) / (dp40 - dp32) + val v = if (!avatarState.isNear) min(lerpF(0f, 0.2f, nearFrac), baseV) else baseV - // Detect if device has a real centered notch (debug override supported) - val hasRealNotch = !MetaballDebug.forceNoNotch && - notchInfo != null && notchInfo.gravity == Gravity.CENTER && notchInfo.bounds.width() > 0 + val showConnector = hasAvatar && expansionProgress == 0f && + avatarState.isDrawing && avatarState.showBlob && dist < maxDist * 1.5f + // Only show gooey mask when collapsing — prevents halo around avatar at rest + val showMetaball = hasAvatar && expansionProgress == 0f && collapseProgress > 0.05f - // Calculate "v" parameter - thickness of connector based on distance - val distance = avatarState.centerY - notchCenterY - val maxDist = avatarSizeExpandedPx - val c = (distance / maxDist).coerceIn(-1f, 1f) + // ═══════════════════════════════════════════════════════════ + // GOOEY BLUR — CONSTANT, not progressive + // Telegram: gooScaleFactor = 2 + factorMult → 3 (HIGH), 3.5 (AVG) + // Effective blur = BLUR_RADIUS × gooScaleFactor + // ═══════════════════════════════════════════════════════════ + val gooScaleFactor = 2f + factorMult + val gooBlurRadius = (ProfileMetaballConstants.BLUR_RADIUS * gooScaleFactor) + .coerceIn(ProfileMetaballConstants.BLUR_MIN, 80f) - 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 { - baseV - } + Box(modifier = modifier.fillMaxSize().clip(RectangleShape)) { - // Show connector when avatar is small enough (isDrawing) and not expanding - // No longer requires hasRealNotch — works with black bar fallback too - val showConnector = hasAvatar && expansionProgress == 0f && avatarState.isDrawing && avatarState.showBlob && distance < maxDist * 1.5f - - // Show metaball layer when collapsing with avatar - val showMetaballLayer = hasAvatar && expansionProgress == 0f - - // Adjusted blur radius based on device performance (factorMult) - val adjustedBlurRadius = ProfileMetaballConstants.BLUR_RADIUS / factorMult - - Box(modifier = modifier - .fillMaxSize() - .clip(RectangleShape) - ) { - // LAYER 1: Metaball shapes with blur + alpha threshold (BLACK shapes only) - if (showMetaballLayer) { + // ───────────────────────────────────────────────────── + // LAYER 1 — gooey metaball shapes (blur + threshold) + // ───────────────────────────────────────────────────── + if (showMetaball) { Canvas( modifier = Modifier .fillMaxSize() .graphicsLayer { - // ColorMatrixColorFilter for alpha threshold (API 31+) - // Replaces AGSL RuntimeShader — same gooey effect - val blurEffect = RenderEffect.createBlurEffect( - adjustedBlurRadius, - adjustedBlurRadius, - Shader.TileMode.DECAL - ) - val thresholdEffect = RenderEffect.createColorFilterEffect( - ColorMatrixColorFilter(GPU_ALPHA_THRESHOLD_MATRIX) - ) - // Chain: blur first (inner), then threshold (outer) - renderEffect = RenderEffect.createChainEffect(thresholdEffect, blurEffect) + renderEffect = RenderEffect + .createChainEffect( + RenderEffect.createColorFilterEffect( + ColorMatrixColorFilter(GPU_ALPHA_THRESHOLD_MATRIX) + ), + RenderEffect.createBlurEffect( + gooBlurRadius, gooBlurRadius, + Shader.TileMode.CLAMP + ) + ) .asComposeRenderEffect() } ) { @@ -528,310 +348,66 @@ fun ProfileMetaballOverlay( } drawIntoCanvas { canvas -> - val nativeCanvas = canvas.nativeCanvas + val nc = canvas.nativeCanvas - // Draw target shape at top (notch or black bar fallback) + // ── Notch / black bar target ── if (showConnector) { - if (hasRealNotch && notchInfo != null && notchInfo.isLikelyCircle) { - nativeCanvas.drawCircle( - notchCenterX, - notchCenterY, - notchRadiusPx, - paint - ) - } else if (hasRealNotch && notchInfo != null && notchInfo.isAccurate && notchInfo.path != null) { - nativeCanvas.drawPath(notchInfo.path, paint) - } else if (hasRealNotch && notchInfo != null) { - val bounds = notchInfo.bounds - val rad = kotlin.math.max(bounds.width(), bounds.height()) / 2f - nativeCanvas.drawRoundRect(bounds, rad, rad, paint) - } else { - // No notch fallback: full-width black bar at top - // Like Telegram's ProfileGooeyView when notchInfo == null - nativeCanvas.drawRect(0f, 0f, screenWidthPx, blackBarHeightPx, paint) - } - } - - // Draw avatar shape - circle or rounded rect depending on state - if (avatarState.showBlob) { - val cx = avatarState.centerX - val cy = avatarState.centerY - val r = avatarState.radius - val cornerR = avatarState.cornerRadius - - // 🔥 Use rectTmp like Telegram's AndroidUtilities.rectTmp - // 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) - // Reuse rectTmp to avoid allocations - rectTmp.set(cx - r, cy - r, cx + r, cy + r) - nativeCanvas.drawRoundRect(rectTmp, cornerR, cornerR, paint) - } - } - - // Draw metaball connector path - if (showConnector && avatarState.showBlob) { - c1.x = notchCenterX - c1.y = notchCenterY - c2.x = avatarState.centerX - c2.y = avatarState.centerY - - if (createMetaballPath(c1, c2, notchRadiusPx, avatarState.radius, v, metaballPath)) { - nativeCanvas.drawPath(metaballPath, paint) - } - } - } - } - } // END if (showMetaballLayer) - - // ═══════════════════════════════════════════════════════════════ - // LAYER 2: Avatar content - UNIFIED BOX with SCALE - // 🔥 FIX: ОДИН Box для всех режимов - предотвращает мигание - // Базовый размер ФИКСИРОВАННЫЙ (baseSizeDp), изменение через graphicsLayer.scale - // ═══════════════════════════════════════════════════════════════ - if (avatarState.showBlob) { - // Базовый размер аватарки (ФИКСИРОВАННЫЙ для Box) - val baseSizeDp = ProfileMetaballConstants.AVATAR_SIZE_EXPANDED - val baseSizePx = with(density) { baseSizeDp.toPx() } - - // Позиция центра (из avatarState для collapse, интерполированная для expansion) - val avatarCenterX = avatarState.centerX - val avatarCenterY = avatarState.centerY - - // ═══════════════════════════════════════════════════════════ - // 🔥 UNIFIED SCALE - БЕЗ резких переключений when - // Всегда вычисляем scale на основе avatarState.radius - // При expansion - дополнительно интерполируем к screenWidth - // ═══════════════════════════════════════════════════════════ - - // Базовый scale из avatarState.radius (работает для collapse И normal) - val baseScale = (avatarState.radius * 2f) / baseSizePx - - // При expansion: дополнительно масштабируем к screenWidth - val targetExpansionScale = screenWidthPx / baseSizePx - val uniformScale = if (expansionProgress > 0f) { - // Плавный переход от baseScale к targetExpansionScale - lerpFloat(baseScale, targetExpansionScale, expansionProgress) - } else { - baseScale - }.coerceAtLeast(0.01f) // Защита от деления на 0 - - // Центр: при expansion двигается к центру header - val targetCenterX = screenWidthPx / 2f - val targetCenterY = headerHeightPx / 2f - val currentCenterX = if (expansionProgress > 0f) { - lerpFloat(avatarCenterX, targetCenterX, expansionProgress) - } else { - avatarCenterX - } - val currentCenterY = if (expansionProgress > 0f) { - lerpFloat(avatarCenterY, targetCenterY, expansionProgress) - } else { - avatarCenterY - } - - // Corner radius: для base size (120dp) - // В normal/collapse: из avatarState, но пересчитанный для base size - // При expansion: круг → квадрат - val cornerRadiusPx: Float = if (expansionProgress > 0f) { - // Expansion: плавно круг → квадрат - lerpFloat(baseSizePx / 2f, 0f, expansionProgress) - } else { - // Normal/Collapse: используем avatarState.cornerRadius - // Пересчитываем для base size: cornerRadius_base = cornerRadius_current / baseScale - (avatarState.cornerRadius / baseScale).coerceAtMost(baseSizePx / 2f) - } - - // Blur только при collapse близко к notch - val applyBlur = expansionProgress == 0f && avatarState.blurRadius > 0.5f - - // Offset: Box имеет ФИКСИРОВАННЫЙ размер, scale применяется от центра - val offsetX = with(density) { (currentCenterX - baseSizePx / 2f).toDp() } - val offsetY = with(density) { (currentCenterY - baseSizePx / 2f).toDp() } - val cornerRadiusDp = with(density) { cornerRadiusPx.toDp() } - - // 🔥 ЕДИНЫЙ BOX - без if/else переключения между composables - Box( - modifier = Modifier - .offset(x = offsetX, y = offsetY) - .width(baseSizeDp) // ФИКСИРОВАННЫЙ - .height(baseSizeDp) // ФИКСИРОВАННЫЙ - .graphicsLayer { - scaleX = uniformScale - scaleY = uniformScale - alpha = avatarState.opacity - if (applyBlur) { - renderEffect = RenderEffect.createBlurEffect( - avatarState.blurRadius, - avatarState.blurRadius, - Shader.TileMode.DECAL - ).asComposeRenderEffect() - } - } - .clip(RoundedCornerShape(cornerRadiusDp)), - contentAlignment = Alignment.Center, - content = avatarContent - ) - } - } -} + drawNotchShape(nc, hasRealNotch, notchInfo, notchCX, notchCY, + notchRadiusPx, screenWidthPx, blackBarPx, paint) -/** - * 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( - collapseProgress: Float, - expansionProgress: Float, - statusBarHeight: Dp, - headerHeight: Dp, - hasAvatar: Boolean, - @Suppress("UNUSED_PARAMETER") avatarColor: ComposeColor, - modifier: Modifier = Modifier, - avatarContent: @Composable BoxScope.() -> Unit = {}, -) { - val configuration = LocalConfiguration.current - val screenWidth = configuration.screenWidthDp.dp - val density = LocalDensity.current - - val screenWidthPx = with(density) { screenWidth.toPx() } - val statusBarHeightPx = with(density) { statusBarHeight.toPx() } - val headerHeightPx = with(density) { headerHeight.toPx() } - val avatarSizeExpandedPx = with(density) { ProfileMetaballConstants.AVATAR_SIZE_EXPANDED.toPx() } - val avatarSizeMinPx = with(density) { ProfileMetaballConstants.AVATAR_SIZE_MIN.toPx() } - - // Fallback notch values for compat mode - // Use black bar center as target (like Telegram's BLACK_KING_BAR) - val blackBarHeightPx = with(density) { BLACK_BAR_HEIGHT_DP.dp.toPx() } - val notchCenterX = screenWidthPx / 2f - val notchCenterY = blackBarHeightPx / 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() } - - // 🔥 FIX: Убраны volatile keys (collapseProgress, expansionProgress) из remember - // derivedStateOf автоматически отслеживает их как зависимости - val avatarState by remember(screenWidthPx, statusBarHeightPx, headerHeightPx, notchCenterX) { - derivedStateOf { - computeAvatarState( - collapseProgress = collapseProgress, + // Drip triangle notch → avatar + drawDripDown(nc, notchCX, notchCY, notchRadiusPx, + dp7 * gooScaleFactor, collapseProgress, paint) + } + + // ── Avatar shape ── + if (avatarState.showBlob) { + drawAvatarShape(nc, avatarState, rectTmp, paint) + + // Drip triangle avatar → notch + if (showConnector) { + drawDripUp(nc, avatarState, dp7 * gooScaleFactor, + collapseProgress, paint) + } + } + + // ── Metaball connector path ── + if (showConnector && avatarState.showBlob) { + c1.x = notchCX; c1.y = notchCY + c2.x = avatarState.centerX; c2.y = avatarState.centerY + if (createMetaballPath(c1, c2, notchRadiusPx, avatarState.radius, v, metaballPath)) { + nc.drawPath(metaballPath, paint) + } + } + } + } + } + + // ───────────────────────────────────────────────────── + // LAYER 2 — avatar content + // ───────────────────────────────────────────────────── + if (avatarState.showBlob) { + AvatarContentBox( + avatarState = avatarState, expansionProgress = expansionProgress, screenWidthPx = screenWidthPx, - statusBarHeightPx = statusBarHeightPx, - headerHeightPx = headerHeightPx, - avatarSizeExpandedPx = avatarSizeExpandedPx, - avatarSizeMinPx = avatarSizeMinPx, - hasAvatar = hasAvatar, - notchCenterX = notchCenterX, - notchCenterY = notchCenterY, - notchRadiusPx = notchRadiusPx, - dp40 = dp40, - dp34 = dp34, - dp32 = dp32, - dp18 = dp18, - dp22 = dp22, - fullCornerRadius = avatarSizeExpandedPx / 2f - ) - } - } - - Box(modifier = modifier - .fillMaxSize() - .clip(RectangleShape) // Clip to bounds - prevents avatar overflow when expanded - ) { - // ═══════════════════════════════════════════════════════════════ - // 🔥 FIX: ОДИН Box для всех режимов - предотвращает мигание - // Базовый размер ФИКСИРОВАННЫЙ, изменение через graphicsLayer.scale - // ═══════════════════════════════════════════════════════════════ - if (avatarState.showBlob) { - val baseSizeDp = ProfileMetaballConstants.AVATAR_SIZE_EXPANDED - val baseSizePx = with(density) { baseSizeDp.toPx() } - val avatarCenterX = avatarState.centerX - val avatarCenterY = avatarState.centerY - - // 🔥 UNIFIED SCALE - БЕЗ резких переключений when - val baseScale = (avatarState.radius * 2f) / baseSizePx - val targetExpansionScale = screenWidthPx / baseSizePx - val uniformScale = if (expansionProgress > 0f) { - lerpFloat(baseScale, targetExpansionScale, expansionProgress) - } else { - baseScale - }.coerceAtLeast(0.01f) - - // Центр: при expansion двигается к центру header - val targetCenterX = screenWidthPx / 2f - val targetCenterY = headerHeightPx / 2f - val currentCenterX = if (expansionProgress > 0f) { - lerpFloat(avatarCenterX, targetCenterX, expansionProgress) - } else { - avatarCenterX - } - val currentCenterY = if (expansionProgress > 0f) { - lerpFloat(avatarCenterY, targetCenterY, expansionProgress) - } else { - avatarCenterY - } - - // Corner radius - val cornerRadiusPx: Float = if (expansionProgress > 0f) { - lerpFloat(baseSizePx / 2f, 0f, expansionProgress) - } else { - (avatarState.cornerRadius / baseScale).coerceAtMost(baseSizePx / 2f) - } - - val offsetX = with(density) { (currentCenterX - baseSizePx / 2f).toDp() } - val offsetY = with(density) { (currentCenterY - baseSizePx / 2f).toDp() } - val cornerRadiusDp = with(density) { cornerRadiusPx.toDp() } - - // 🔥 ЕДИНЫЙ BOX - Box( - modifier = Modifier - .offset(x = offsetX, y = offsetY) - .width(baseSizeDp) - .height(baseSizeDp) - .graphicsLayer { - scaleX = uniformScale - scaleY = uniformScale - alpha = avatarState.opacity - } - .clip(RoundedCornerShape(cornerRadiusDp)), - contentAlignment = Alignment.Center, - content = avatarContent + headerHPx = headerHPx, + avatarExpandPx = avatarExpandPx, + applyContentBlur = expansionProgress == 0f && avatarState.contentBlurRadius > 0.5f, + avatarContent = avatarContent ) } } } -/** - * CPU alpha threshold matrix — from Telegram's ProfileGooeyView.CPUImpl. - * Operates on downscaled black shapes: - * Alpha row: newAlpha = oldAlpha * 60 - 7500 - * Threshold at alpha ~125/255. RGB forced to 0 (black mask). - */ -private val CPU_ALPHA_THRESHOLD_MATRIX = floatArrayOf( - 0f, 0f, 0f, 0f, 0f, - 0f, 0f, 0f, 0f, 0f, - 0f, 0f, 0f, 0f, 0f, - 0f, 0f, 0f, 51f, -6375f // Match GPU threshold: cutoff at alpha ~125/255 -) +// ═══════════════════════════════════════════════════════════════════════ +// CPU PATH — any Android version, uses Stack Blur + threshold bitmap. +// +// Telegram CPUImpl: scaleConst = 5, blurRadius = intensity×2/scaleConst = 6 +// We use scaleConst = 3 for better quality, blurRadius = 15×2/3 = 10 +// Effective blur = 10×3 = 30 (Telegram: 6×5 = 30 — same!) +// ═══════════════════════════════════════════════════════════════════════ -/** - * CPU path for metaball effect — works on any Android version. - * Matches Telegram's ProfileGooeyView.CPUImpl: - * 1. Draw black shapes (target + avatar + connector) into a downscaled bitmap - * 2. Apply stack blur on CPU - * 3. Draw with ColorMatrixColorFilter for alpha threshold - * 4. Composite with SRC_ATOP - */ @Composable fun ProfileMetaballOverlayCpu( collapseProgress: Float, @@ -848,102 +424,87 @@ fun ProfileMetaballOverlayCpu( val screenWidth = configuration.screenWidthDp.dp val density = LocalDensity.current - val screenWidthPx = with(density) { screenWidth.toPx() } - val statusBarHeightPx = with(density) { statusBarHeight.toPx() } - val headerHeightPx = with(density) { headerHeight.toPx() } - val avatarSizeExpandedPx = with(density) { ProfileMetaballConstants.AVATAR_SIZE_EXPANDED.toPx() } - val avatarSizeMinPx = with(density) { ProfileMetaballConstants.AVATAR_SIZE_MIN.toPx() } + val screenWidthPx = with(density) { screenWidth.toPx() } + val statusBarHPx = with(density) { statusBarHeight.toPx() } + val headerHPx = with(density) { headerHeight.toPx() } + val avatarExpandPx = with(density) { ProfileMetaballConstants.AVATAR_SIZE_EXPANDED.toPx() } + val avatarMinPx = with(density) { ProfileMetaballConstants.AVATAR_SIZE_MIN.toPx() } + val blackBarPx = with(density) { BLACK_BAR_HEIGHT_DP.dp.toPx() } - // Get notch info (debug override supported) + // ── Notch ── val notchInfo = remember { NotchInfoUtils.getInfo(context) } val hasRealNotch = !MetaballDebug.forceNoNotch && notchInfo != null && notchInfo.gravity == Gravity.CENTER && notchInfo.bounds.width() > 0 - val blackBarHeightPx = with(density) { BLACK_BAR_HEIGHT_DP.dp.toPx() } - val notchRadiusPx = remember(notchInfo) { if (hasRealNotch && notchInfo != null) { - if (notchInfo.isLikelyCircle) { - min(notchInfo.bounds.width(), notchInfo.bounds.height()) / 2f - } else { - kotlin.math.max(notchInfo.bounds.width(), notchInfo.bounds.height()) / 2f - } - } else { - with(density) { ProfileMetaballConstants.FALLBACK_CAMERA_SIZE.toPx() } - } + if (notchInfo.isLikelyCircle) min(notchInfo.bounds.width(), notchInfo.bounds.height()) / 2f + else max(notchInfo.bounds.width(), notchInfo.bounds.height()) / 2f + } else with(density) { ProfileMetaballConstants.FALLBACK_CAMERA_SIZE.toPx() } } - val notchCenterX = remember(notchInfo, screenWidthPx) { + val notchCX = remember(notchInfo, screenWidthPx) { if (hasRealNotch && notchInfo != null) notchInfo.bounds.centerX() else screenWidthPx / 2f } - val notchCenterY = remember(notchInfo) { - if (hasRealNotch && notchInfo != null && notchInfo.isLikelyCircle) { + val notchCY = remember(notchInfo) { + if (hasRealNotch && notchInfo != null && notchInfo.isLikelyCircle) notchInfo.bounds.bottom - notchInfo.bounds.width() / 2f - } else if (hasRealNotch && notchInfo != null) { - notchInfo.bounds.centerY() - } else { - blackBarHeightPx / 2f - } + else if (hasRealNotch && notchInfo != null) notchInfo.bounds.centerY() + else blackBarPx / 2f } - // Thresholds + // ── DP helpers ── 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 dp7 = with(density) { 7.dp.toPx() } - val avatarState by remember(screenWidthPx, statusBarHeightPx, headerHeightPx, notchCenterX, notchCenterY, notchRadiusPx) { + // ── Avatar state ── + val avatarState by remember( + screenWidthPx, statusBarHPx, headerHPx, notchCX, notchCY, notchRadiusPx + ) { derivedStateOf { computeAvatarState( - collapseProgress = collapseProgress, - expansionProgress = expansionProgress, - screenWidthPx = screenWidthPx, - statusBarHeightPx = statusBarHeightPx, - headerHeightPx = headerHeightPx, - avatarSizeExpandedPx = avatarSizeExpandedPx, - avatarSizeMinPx = avatarSizeMinPx, - hasAvatar = hasAvatar, - notchCenterX = notchCenterX, - notchCenterY = notchCenterY, - notchRadiusPx = notchRadiusPx, - dp40 = dp40, dp34 = dp34, dp32 = dp32, dp18 = dp18, dp22 = dp22, - fullCornerRadius = avatarSizeExpandedPx / 2f + collapseProgress, expansionProgress, + screenWidthPx, statusBarHPx, headerHPx, + avatarExpandPx, avatarMinPx, hasAvatar, + notchCX, notchCY, notchRadiusPx, + dp40, dp34, dp32, dp18, dp22, + avatarExpandPx / 2f ) } } - // Reusable objects - val metaballPath = remember { android.graphics.Path() } + // ── Connector ── + val metaballPath = remember { Path() } val c1 = remember { Point() } val c2 = remember { Point() } val rectTmp = remember { RectF() } - // Connector calculations - val distance = avatarState.centerY - notchCenterY - val maxDist = avatarSizeExpandedPx - val cParam = (distance / maxDist).coerceIn(-1f, 1f) + val dist = avatarState.centerY - notchCY + val maxDist = avatarExpandPx + val cParam = (dist / maxDist).coerceIn(-1f, 1f) val baseV = ((1f - cParam / 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 baseV + val nearFrac = if (avatarState.isNear) 1f + else 1f - (avatarState.radius - dp32) / (dp40 - dp32) + val v = if (!avatarState.isNear) min(lerpF(0f, 0.2f, nearFrac), baseV) else baseV - val showConnector = hasAvatar && expansionProgress == 0f && avatarState.isDrawing && avatarState.showBlob && distance < maxDist * 1.5f - val showMetaballLayer = hasAvatar && expansionProgress == 0f + val showConnector = hasAvatar && expansionProgress == 0f && + avatarState.isDrawing && avatarState.showBlob && dist < maxDist * 1.5f + // Only show gooey mask when collapsing — prevents halo around avatar at rest + val showMetaball = hasAvatar && expansionProgress == 0f && collapseProgress > 0.05f - // CPU-specific: downscaled bitmap for blur - // Reduced from 5 to 3 for higher resolution — smoother edges after alpha threshold + // ── Offscreen bitmap ── val scaleConst = 3f - val optimizedW = min(with(density) { 120.dp.toPx() }.toInt(), screenWidthPx.toInt()) - val optimizedH = min(with(density) { 220.dp.toPx() }.toInt(), headerHeightPx.toInt() + blackBarHeightPx.toInt()) - val bitmapW = (optimizedW / scaleConst).toInt().coerceAtLeast(1) - val bitmapH = (optimizedH / scaleConst).toInt().coerceAtLeast(1) + val optW = min(with(density) { 120.dp.toPx() }.toInt(), screenWidthPx.toInt()) + val optH = min(with(density) { 220.dp.toPx() }.toInt(), headerHPx.toInt() + blackBarPx.toInt()) + val bmpW = (optW / scaleConst).toInt().coerceAtLeast(1) + val bmpH = (optH / scaleConst).toInt().coerceAtLeast(1) - val offscreenBitmap = remember(bitmapW, bitmapH) { - android.graphics.Bitmap.createBitmap(bitmapW, bitmapH, android.graphics.Bitmap.Config.ARGB_8888) + val offBitmap = remember(bmpW, bmpH) { + android.graphics.Bitmap.createBitmap(bmpW, bmpH, android.graphics.Bitmap.Config.ARGB_8888) } - val offscreenCanvas = remember(offscreenBitmap) { - android.graphics.Canvas(offscreenBitmap) - } - - // Reusable paints + val offCanvas = remember(offBitmap) { android.graphics.Canvas(offBitmap) } val blackPaint = remember { android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply { color = android.graphics.Color.BLACK @@ -951,294 +512,319 @@ fun ProfileMetaballOverlayCpu( } } val filterPaint = remember { - android.graphics.Paint(android.graphics.Paint.FILTER_BITMAP_FLAG or android.graphics.Paint.ANTI_ALIAS_FLAG).apply { + android.graphics.Paint( + android.graphics.Paint.FILTER_BITMAP_FLAG or android.graphics.Paint.ANTI_ALIAS_FLAG + ).apply { isFilterBitmap = true colorFilter = ColorMatrixColorFilter(CPU_ALPHA_THRESHOLD_MATRIX) } } - - // Cleanup bitmap on dispose - DisposableEffect(offscreenBitmap) { - onDispose { - if (!offscreenBitmap.isRecycled) { - offscreenBitmap.recycle() - } + val srcAtopPaint = remember { + android.graphics.Paint( + android.graphics.Paint.FILTER_BITMAP_FLAG or android.graphics.Paint.ANTI_ALIAS_FLAG + ).apply { + isFilterBitmap = true + xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC_ATOP) } } - Box(modifier = modifier - .fillMaxSize() - .clip(RectangleShape) - ) { - // LAYER 1: CPU-rendered metaball shapes - if (showMetaballLayer) { + DisposableEffect(offBitmap) { + onDispose { if (!offBitmap.isRecycled) offBitmap.recycle() } + } + + Box(modifier = modifier.fillMaxSize().clip(RectangleShape)) { + + // ───────────────────────────────────────────────────── + // LAYER 1 — CPU gooey shapes + // ───────────────────────────────────────────────────── + if (showMetaball) { Canvas(modifier = Modifier.fillMaxSize()) { drawIntoCanvas { canvas -> - val nativeCanvas = canvas.nativeCanvas - val optimizedOffsetX = (screenWidthPx - optimizedW) / 2f + val nc = canvas.nativeCanvas + val offX = (screenWidthPx - optW) / 2f - // Clear offscreen bitmap - offscreenBitmap.eraseColor(0) - - // Draw shapes into downscaled bitmap - offscreenCanvas.save() - offscreenCanvas.scale( - offscreenBitmap.width.toFloat() / optimizedW, - offscreenBitmap.height.toFloat() / optimizedH + offBitmap.eraseColor(0) + offCanvas.save() + offCanvas.scale( + offBitmap.width.toFloat() / optW, + offBitmap.height.toFloat() / optH ) - offscreenCanvas.translate(-optimizedOffsetX, 0f) + offCanvas.translate(-offX, 0f) - // Draw target (notch or black bar) + // Notch / black bar if (showConnector) { - if (hasRealNotch && notchInfo != null && notchInfo.isLikelyCircle) { - val rad = min(notchInfo.bounds.width(), notchInfo.bounds.height()) / 2f - offscreenCanvas.drawCircle( - notchInfo.bounds.centerX(), - notchInfo.bounds.bottom - notchInfo.bounds.width() / 2f, - rad, blackPaint - ) - } else if (hasRealNotch && notchInfo != null && notchInfo.isAccurate && notchInfo.path != null) { - offscreenCanvas.drawPath(notchInfo.path, blackPaint) - } else if (hasRealNotch && notchInfo != null) { - val bounds = notchInfo.bounds - val rad = kotlin.math.max(bounds.width(), bounds.height()) / 2f - offscreenCanvas.drawRoundRect(bounds, rad, rad, blackPaint) - } else { - // No notch: draw black bar - offscreenCanvas.drawRect(0f, 0f, screenWidthPx, blackBarHeightPx, blackPaint) - } + drawNotchShape(offCanvas, hasRealNotch, notchInfo, notchCX, notchCY, + notchRadiusPx, screenWidthPx, blackBarPx, blackPaint) + drawDripDown(offCanvas, notchCX, notchCY, notchRadiusPx, + dp7 * 3f, collapseProgress, blackPaint) } - // Draw avatar shape + // Avatar shape if (avatarState.showBlob) { - val cx = avatarState.centerX - val cy = avatarState.centerY - val r = avatarState.radius - val cornerR = avatarState.cornerRadius - - if (cornerR >= r * 0.95f) { - offscreenCanvas.drawCircle(cx, cy, r, blackPaint) - } else { - rectTmp.set(cx - r, cy - r, cx + r, cy + r) - offscreenCanvas.drawRoundRect(rectTmp, cornerR, cornerR, blackPaint) + drawAvatarShape(offCanvas, avatarState, rectTmp, blackPaint) + if (showConnector) { + drawDripUp(offCanvas, avatarState, dp7 * 3f, + collapseProgress, blackPaint) } } - // Draw metaball connector + // Connector if (showConnector && avatarState.showBlob) { - c1.x = notchCenterX - c1.y = notchCenterY - c2.x = avatarState.centerX - c2.y = avatarState.centerY - + c1.x = notchCX; c1.y = notchCY + c2.x = avatarState.centerX; c2.y = avatarState.centerY if (createMetaballPath(c1, c2, notchRadiusPx, avatarState.radius, v, metaballPath)) { - offscreenCanvas.drawPath(metaballPath, blackPaint) + offCanvas.drawPath(metaballPath, blackPaint) } } - offscreenCanvas.restore() + offCanvas.restore() - // Apply stack blur on CPU - val blurRadius = (ProfileMetaballConstants.BLUR_RADIUS * 2 / scaleConst).toInt().coerceAtLeast(1) - stackBlurBitmapInPlace(offscreenBitmap, blurRadius) + // ── Stack blur — CONSTANT radius ── + val blurRadius = (ProfileMetaballConstants.BLUR_RADIUS * 2 / scaleConst) + .toInt().coerceAtLeast(1) + stackBlurBitmapInPlace(offBitmap, blurRadius) - // Draw blurred bitmap with color matrix filter (alpha threshold) - nativeCanvas.save() - nativeCanvas.translate(optimizedOffsetX, 0f) - nativeCanvas.scale( - optimizedW.toFloat() / offscreenBitmap.width, - optimizedH.toFloat() / offscreenBitmap.height + // ── Draw with threshold filter ── + nc.save() + nc.translate(offX, 0f) + nc.scale( + optW.toFloat() / offBitmap.width, + optH.toFloat() / offBitmap.height ) - nativeCanvas.drawBitmap(offscreenBitmap, 0f, 0f, filterPaint) - nativeCanvas.restore() + nc.drawBitmap(offBitmap, 0f, 0f, filterPaint) + nc.drawBitmap(offBitmap, 0f, 0f, srcAtopPaint) + nc.restore() } } } - // LAYER 2: Avatar content (same as GPU path) + // ───────────────────────────────────────────────────── + // LAYER 2 — avatar content + // ───────────────────────────────────────────────────── if (avatarState.showBlob) { - val baseSizeDp = ProfileMetaballConstants.AVATAR_SIZE_EXPANDED - val baseSizePx = with(density) { baseSizeDp.toPx() } - - val baseScale = (avatarState.radius * 2f) / baseSizePx - val targetExpansionScale = screenWidthPx / baseSizePx - val uniformScale = if (expansionProgress > 0f) { - lerpFloat(baseScale, targetExpansionScale, expansionProgress) - } else { - baseScale - }.coerceAtLeast(0.01f) - - val avatarCenterX = avatarState.centerX - val avatarCenterY = avatarState.centerY - val targetCenterX = screenWidthPx / 2f - val targetCenterY = headerHeightPx / 2f - val currentCenterX = if (expansionProgress > 0f) lerpFloat(avatarCenterX, targetCenterX, expansionProgress) else avatarCenterX - val currentCenterY = if (expansionProgress > 0f) lerpFloat(avatarCenterY, targetCenterY, expansionProgress) else avatarCenterY - - val cornerRadiusPx: Float = if (expansionProgress > 0f) { - lerpFloat(baseSizePx / 2f, 0f, expansionProgress) - } else { - (avatarState.cornerRadius / baseScale).coerceAtMost(baseSizePx / 2f) - } - - val offsetX = with(density) { (currentCenterX - baseSizePx / 2f).toDp() } - val offsetY = with(density) { (currentCenterY - baseSizePx / 2f).toDp() } - val cornerRadiusDp = with(density) { cornerRadiusPx.toDp() } - - Box( - modifier = Modifier - .offset(x = offsetX, y = offsetY) - .width(baseSizeDp) - .height(baseSizeDp) - .graphicsLayer { - scaleX = uniformScale - scaleY = uniformScale - alpha = avatarState.opacity - } - .clip(RoundedCornerShape(cornerRadiusDp)), - contentAlignment = Alignment.Center, - content = avatarContent + AvatarContentBox( + avatarState = avatarState, + expansionProgress = expansionProgress, + screenWidthPx = screenWidthPx, + headerHPx = headerHPx, + avatarExpandPx = avatarExpandPx, + applyContentBlur = false, // No RenderEffect on CPU path + avatarContent = avatarContent ) } } } -/** - * DEBUG: Temporary toggle to force a specific rendering path. - * Set forceMode to test different paths on your device: - * - null: auto-detect (default production behavior) - * - "gpu": force GPU path (requires API 31+) - * - "cpu": force CPU bitmap path - * - "compat": force compat/noop path - * - * Set forceNoNotch = true to simulate no-notch device (black bar fallback). - * - * TODO: Remove before release! - */ -object MetaballDebug { - var forceMode: String? = null // "gpu", "cpu", "compat", or null - var forceNoNotch: Boolean = false // true = pretend no notch exists +// ═══════════════════════════════════════════════════════════════════════ +// COMPAT PATH — no metaball, just position / scale / opacity +// ═══════════════════════════════════════════════════════════════════════ + +@Composable +fun ProfileMetaballOverlayCompat( + collapseProgress: Float, + expansionProgress: Float, + statusBarHeight: Dp, + headerHeight: Dp, + hasAvatar: Boolean, + @Suppress("UNUSED_PARAMETER") avatarColor: ComposeColor, + modifier: Modifier = Modifier, + avatarContent: @Composable BoxScope.() -> Unit = {}, +) { + val configuration = LocalConfiguration.current + val screenWidth = configuration.screenWidthDp.dp + val density = LocalDensity.current + + val screenWidthPx = with(density) { screenWidth.toPx() } + val statusBarHPx = with(density) { statusBarHeight.toPx() } + val headerHPx = with(density) { headerHeight.toPx() } + val avatarExpandPx = with(density) { ProfileMetaballConstants.AVATAR_SIZE_EXPANDED.toPx() } + val avatarMinPx = with(density) { ProfileMetaballConstants.AVATAR_SIZE_MIN.toPx() } + val blackBarPx = with(density) { BLACK_BAR_HEIGHT_DP.dp.toPx() } + + val notchCX = screenWidthPx / 2f + val notchCY = blackBarPx / 2f + val notchR = with(density) { ProfileMetaballConstants.FALLBACK_CAMERA_SIZE.toPx() } + + 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(screenWidthPx, statusBarHPx, headerHPx, notchCX) { + derivedStateOf { + computeAvatarState( + collapseProgress, expansionProgress, + screenWidthPx, statusBarHPx, headerHPx, + avatarExpandPx, avatarMinPx, hasAvatar, + notchCX, notchCY, notchR, + dp40, dp34, dp32, dp18, dp22, + avatarExpandPx / 2f + ) + } + } + + Box(modifier = modifier.fillMaxSize().clip(RectangleShape)) { + if (avatarState.showBlob) { + AvatarContentBox( + avatarState = avatarState, + expansionProgress = expansionProgress, + screenWidthPx = screenWidthPx, + headerHPx = headerHPx, + avatarExpandPx = avatarExpandPx, + applyContentBlur = false, + avatarContent = avatarContent + ) + } + } } -/** - * DEBUG: Floating panel with buttons to switch metaball rendering path. - * Place inside a Box (e.g. profile header) — it aligns to bottom-center. - * TODO: Remove before release! - */ +// ═══════════════════════════════════════════════════════════════════════ +// Shared helpers +// ═══════════════════════════════════════════════════════════════════════ + +/** Avatar content Box — shared between all three paths. */ @Composable -fun MetaballDebugPanel(modifier: Modifier = Modifier) { - var currentMode by remember { mutableStateOf(MetaballDebug.forceMode) } - var noNotch by remember { mutableStateOf(MetaballDebug.forceNoNotch) } +private fun AvatarContentBox( + avatarState: AvatarState, + expansionProgress: Float, + screenWidthPx: Float, + headerHPx: Float, + avatarExpandPx: Float, + applyContentBlur: Boolean, + avatarContent: @Composable BoxScope.() -> Unit, +) { + val density = LocalDensity.current + val baseSizeDp = ProfileMetaballConstants.AVATAR_SIZE_EXPANDED + val baseSizePx = avatarExpandPx - val context = LocalContext.current - val perfClass = remember { DevicePerformanceClass.get(context) } + val baseScale = (avatarState.radius * 2f) / baseSizePx + val targetScale = screenWidthPx / baseSizePx + val scale = (if (expansionProgress > 0f) lerpF(baseScale, targetScale, expansionProgress) + else baseScale).coerceAtLeast(0.01f) - Column( - modifier = modifier - .fillMaxWidth() - .padding(horizontal = 12.dp) - .background( - ComposeColor.Black.copy(alpha = 0.75f), - RoundedCornerShape(12.dp) - ) - .padding(12.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - // Title - Text( - text = "Metaball Debug | API ${Build.VERSION.SDK_INT} | $perfClass", - color = ComposeColor.White, - fontSize = 11.sp, - fontWeight = FontWeight.Bold - ) + val cx = if (expansionProgress > 0f) + lerpF(avatarState.centerX, screenWidthPx / 2f, expansionProgress) else avatarState.centerX + val cy = if (expansionProgress > 0f) + lerpF(avatarState.centerY, headerHPx / 2f, expansionProgress) else avatarState.centerY - // Mode buttons row - Row( - horizontalArrangement = Arrangement.spacedBy(6.dp), - modifier = Modifier.fillMaxWidth() - ) { - val modes = listOf(null to "Auto", "gpu" to "GPU", "cpu" to "CPU", "compat" to "Compat") - modes.forEach { (mode, label) -> - val isSelected = currentMode == mode - Box( - modifier = Modifier - .weight(1f) - .clip(RoundedCornerShape(8.dp)) - .background( - if (isSelected) ComposeColor(0xFF4CAF50) else ComposeColor.White.copy(alpha = 0.15f) + val cornerPx = if (expansionProgress > 0f) lerpF(baseSizePx / 2f, 0f, expansionProgress) + else (avatarState.cornerRadius / baseScale).coerceAtMost(baseSizePx / 2f) + + val finalAlpha = (avatarState.opacity * avatarState.imageAlpha).coerceIn(0f, 1f) + + val offX = with(density) { (cx - baseSizePx / 2f).toDp() } + val offY = with(density) { (cy - baseSizePx / 2f).toDp() } + val cornerDp = with(density) { cornerPx.toDp() } + + Box( + modifier = Modifier + .offset(x = offX, y = offY) + .width(baseSizeDp) + .height(baseSizeDp) + .graphicsLayer { + scaleX = scale + scaleY = scale + alpha = finalAlpha + if (applyContentBlur && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + renderEffect = RenderEffect + .createBlurEffect( + avatarState.contentBlurRadius, + avatarState.contentBlurRadius, + Shader.TileMode.CLAMP ) - .border( - width = 1.dp, - color = if (isSelected) ComposeColor(0xFF4CAF50) else ComposeColor.White.copy(alpha = 0.3f), - shape = RoundedCornerShape(8.dp) - ) - .clickable { - MetaballDebug.forceMode = mode - currentMode = mode - } - .padding(vertical = 8.dp), - contentAlignment = Alignment.Center - ) { - Text( - text = label, - color = ComposeColor.White, - fontSize = 12.sp, - fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal - ) + .asComposeRenderEffect() } } - } + .clip(RoundedCornerShape(cornerDp)), + contentAlignment = Alignment.Center, + content = avatarContent + ) +} - // No-notch toggle - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier.fillMaxWidth() - ) { - Text( - text = "Force no-notch (black bar)", - color = ComposeColor.White, - fontSize = 12.sp - ) - Switch( - checked = noNotch, - onCheckedChange = { - MetaballDebug.forceNoNotch = it - noNotch = it - }, - colors = SwitchDefaults.colors( - checkedThumbColor = ComposeColor(0xFF4CAF50), - checkedTrackColor = ComposeColor(0xFF4CAF50).copy(alpha = 0.5f) - ) - ) - } +// ── Shape drawing helpers ── - // Current active path info - val activePath = when (currentMode) { - "gpu" -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) "GPU (forced)" else "GPU needs API 31!" - "cpu" -> "CPU (forced)" - "compat" -> "Compat (forced)" - else -> when { - Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && perfClass >= PerformanceClass.AVERAGE -> "GPU (auto)" - perfClass >= PerformanceClass.HIGH -> "CPU (auto)" - else -> "Compat (auto)" - } - } - Text( - text = "Active: $activePath" + if (noNotch) " + no-notch" else "", - color = ComposeColor(0xFF4CAF50), - fontSize = 11.sp, - fontWeight = FontWeight.Medium - ) +private fun drawNotchShape( + canvas: android.graphics.Canvas, + hasRealNotch: Boolean, + notchInfo: NotchInfoUtils.NotchInfo?, + cx: Float, cy: Float, r: Float, + screenW: Float, blackBarH: Float, + paint: android.graphics.Paint +) { + if (hasRealNotch && notchInfo != null && notchInfo.isLikelyCircle) { + canvas.drawCircle(cx, cy, r, paint) + } else if (hasRealNotch && notchInfo != null && notchInfo.isAccurate && notchInfo.path != null) { + canvas.drawPath(notchInfo.path, paint) + } else if (hasRealNotch && notchInfo != null) { + val b = notchInfo.bounds + val rr = max(b.width(), b.height()) / 2f + canvas.drawRoundRect(b, rr, rr, paint) + } else { + canvas.drawRect(0f, 0f, screenW, blackBarH, paint) } } -/** - * Auto-selecting wrapper — 3-tier architecture matching Telegram's ProfileGooeyView: - * 1. GPU path (Android 12+, average+ performance): RenderEffect blur + ColorMatrixColorFilter - * 2. CPU path (any Android, high performance): Bitmap blur + ColorMatrixColorFilter - * 3. Noop path (low-end devices): Simple scale/opacity animation - */ +private fun drawDripDown( + canvas: android.graphics.Canvas, + notchCX: Float, notchCY: Float, notchR: Float, + maxDripH: Float, collapseProgress: Float, + paint: android.graphics.Paint +) { + val h = lerpF(0f, maxDripH, (collapseProgress * 2f).coerceIn(0f, 1f)) + if (h > 0.5f) { + val p = Path() + p.moveTo(notchCX - h / 2f, notchCY + notchR * 0.5f) + p.lineTo(notchCX, notchCY + notchR + h) + p.lineTo(notchCX + h / 2f, notchCY + notchR * 0.5f) + p.close() + canvas.drawPath(p, paint) + } +} + +private fun drawDripUp( + canvas: android.graphics.Canvas, + state: AvatarState, maxDripH: Float, + collapseProgress: Float, paint: android.graphics.Paint +) { + val h = lerpF(0f, maxDripH, (collapseProgress * 2f).coerceIn(0f, 1f)) + if (h > 0.5f) { + val p = Path() + val r = state.radius + val cos45r = (cos(PI / 4) * r).toFloat() + p.moveTo(state.centerX - r, state.centerY - cos45r) + p.lineTo(state.centerX, state.centerY - r - h * 0.25f) + p.lineTo(state.centerX + r, state.centerY - cos45r) + p.close() + canvas.drawPath(p, paint) + } +} + +private fun drawAvatarShape( + canvas: android.graphics.Canvas, + state: AvatarState, + rectTmp: RectF, + paint: android.graphics.Paint +) { + val cx = state.centerX; val cy = state.centerY + val r = state.radius; val cr = state.cornerRadius + if (cr >= r * 0.95f) { + canvas.drawCircle(cx, cy, r, paint) + } else { + rectTmp.set(cx - r, cy - r, cx + r, cy + r) + canvas.drawRoundRect(rectTmp, cr, cr, paint) + } +} + +// ═══════════════════════════════════════════════════════════════════════ +// Debug + Auto-selector +// ═══════════════════════════════════════════════════════════════════════ + +object MetaballDebug { + var forceMode: String? = null + var forceNoNotch: Boolean = false +} + @Composable fun ProfileMetaballEffect( collapseProgress: Float, @@ -1251,75 +837,43 @@ fun ProfileMetaballEffect( avatarContent: @Composable BoxScope.() -> Unit = {}, ) { val context = LocalContext.current - val performanceClass = remember { DevicePerformanceClass.get(context) } + val perfClass = remember { DevicePerformanceClass.get(context) } - // Debug: log which path is selected - val selectedPath = when (MetaballDebug.forceMode) { - "gpu" -> "GPU (forced)" - "cpu" -> "CPU (forced)" - "compat" -> "Compat (forced)" - else -> when { - Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && performanceClass >= PerformanceClass.AVERAGE -> "GPU (auto)" - performanceClass >= PerformanceClass.HIGH -> "CPU (auto)" - else -> "Compat (auto)" - } - } - LaunchedEffect(selectedPath) { - Log.d("MetaballDebug", "Rendering path: $selectedPath, forceNoNotch: ${MetaballDebug.forceNoNotch}, perf: $performanceClass, API: ${Build.VERSION.SDK_INT}") - } - - // Resolve actual mode val useGpu = when (MetaballDebug.forceMode) { - "gpu" -> Build.VERSION.SDK_INT >= Build.VERSION_CODES.S // still need API 31 - "cpu" -> false + "gpu" -> Build.VERSION.SDK_INT >= Build.VERSION_CODES.S + "cpu" -> false "compat" -> false - else -> Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && performanceClass >= PerformanceClass.AVERAGE + else -> Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && + perfClass >= PerformanceClass.AVERAGE } val useCpu = when (MetaballDebug.forceMode) { - "gpu" -> false - "cpu" -> true + "gpu" -> false + "cpu" -> true "compat" -> false - else -> !useGpu && performanceClass >= PerformanceClass.HIGH + else -> !useGpu && perfClass >= PerformanceClass.HIGH } when { useGpu -> { - val factorMult = if (performanceClass == PerformanceClass.HIGH) 1f else 1.5f + val factorMult = if (perfClass == PerformanceClass.HIGH) 1f else 1.5f ProfileMetaballOverlay( - collapseProgress = collapseProgress, - expansionProgress = expansionProgress, - statusBarHeight = statusBarHeight, - headerHeight = headerHeight, - hasAvatar = hasAvatar, - avatarColor = avatarColor, - factorMult = factorMult, - modifier = modifier, - avatarContent = avatarContent - ) - } - useCpu -> { - ProfileMetaballOverlayCpu( - collapseProgress = collapseProgress, - expansionProgress = expansionProgress, - statusBarHeight = statusBarHeight, - headerHeight = headerHeight, - hasAvatar = hasAvatar, - avatarColor = avatarColor, - modifier = modifier, - avatarContent = avatarContent - ) - } - else -> { - ProfileMetaballOverlayCompat( - collapseProgress = collapseProgress, - expansionProgress = expansionProgress, - statusBarHeight = statusBarHeight, - headerHeight = headerHeight, - hasAvatar = hasAvatar, - avatarColor = avatarColor, - modifier = modifier, - avatarContent = avatarContent + collapseProgress, expansionProgress, + statusBarHeight, headerHeight, + hasAvatar, avatarColor, factorMult, + modifier, avatarContent ) } + useCpu -> ProfileMetaballOverlayCpu( + collapseProgress, expansionProgress, + statusBarHeight, headerHeight, + hasAvatar, avatarColor, + modifier, avatarContent + ) + else -> ProfileMetaballOverlayCompat( + collapseProgress, expansionProgress, + statusBarHeight, headerHeight, + hasAvatar, avatarColor, + modifier, avatarContent + ) } } 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 edc1d35..c999d6f 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 @@ -409,6 +409,28 @@ fun ProfileScreen( hasTriggeredExpandHaptic = false } } + + // Haptic on collapse — light tick when avatar reaches notch + var hasTriggeredCollapseHaptic by remember { mutableStateOf(false) } + LaunchedEffect(collapseProgress) { + if (collapseProgress >= 0.95f && !hasTriggeredCollapseHaptic) { + hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove) + hasTriggeredCollapseHaptic = true + } else if (collapseProgress < 0.5f) { + hasTriggeredCollapseHaptic = false + } + } + + // Haptic on snap back — when avatar snaps back from expanded + var hasTriggeredSnapBackHaptic by remember { mutableStateOf(false) } + LaunchedEffect(isPulledDown, expansionProgress) { + if (!isPulledDown && expansionProgress < 0.05f && hasTriggeredSnapBackHaptic) { + hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove) + hasTriggeredSnapBackHaptic = false + } else if (isPulledDown) { + hasTriggeredSnapBackHaptic = true + } + } // DEBUG LOGS // ═══════════════════════════════════════════════════════════════