diff --git a/app/src/main/java/com/rosetta/messenger/MainActivity.kt b/app/src/main/java/com/rosetta/messenger/MainActivity.kt index 7e2a8b1..2ac0ad0 100644 --- a/app/src/main/java/com/rosetta/messenger/MainActivity.kt +++ b/app/src/main/java/com/rosetta/messenger/MainActivity.kt @@ -542,11 +542,16 @@ fun MainScreen( var showLogsScreen by remember { mutableStateOf(false) } var showCrashLogsScreen by remember { mutableStateOf(false) } var showBiometricScreen by remember { mutableStateOf(false) } + var showAppearanceScreen by remember { mutableStateOf(false) } // ProfileViewModel для логов val profileViewModel: com.rosetta.messenger.ui.settings.ProfileViewModel = androidx.lifecycle.viewmodel.compose.viewModel() val profileState by profileViewModel.state.collectAsState() + // Appearance: background blur color preference + val prefsManager = remember { com.rosetta.messenger.data.PreferencesManager(context) } + val backgroundBlurColorId by prefsManager.backgroundBlurColorId.collectAsState(initial = "avatar") + // AvatarRepository для работы с аватарами val avatarRepository = remember(accountPublicKey) { if (accountPublicKey.isNotBlank() && accountPublicKey != "04c266b98ae5") { @@ -608,10 +613,61 @@ fun MainScreen( // TODO: Show new chat screen }, onUserSelect = { selectedChatUser -> selectedUser = selectedChatUser }, + backgroundBlurColorId = backgroundBlurColorId, avatarRepository = avatarRepository, onLogout = onLogout ) + // ═══════════════════════════════════════════════════════════ + // Profile Screen — MUST be before sub-screens so it stays + // visible beneath them during swipe-back animation + // ═══════════════════════════════════════════════════════════ + SwipeBackContainer( + isVisible = showProfileScreen, + onBack = { showProfileScreen = false }, + isDarkTheme = isDarkTheme + ) { + // Экран профиля + ProfileScreen( + isDarkTheme = isDarkTheme, + accountName = accountName, + accountUsername = accountUsername, + accountPublicKey = accountPublicKey, + accountPrivateKeyHash = privateKeyHash, + onBack = { showProfileScreen = false }, + onSaveProfile = { name, username -> + accountName = name + accountUsername = username + mainScreenScope.launch { + onAccountInfoUpdated() + } + }, + onLogout = onLogout, + onNavigateToTheme = { + showThemeScreen = true + }, + onNavigateToAppearance = { + showAppearanceScreen = true + }, + onNavigateToSafety = { + showSafetyScreen = true + }, + onNavigateToLogs = { + showLogsScreen = true + }, + onNavigateToCrashLogs = { + showCrashLogsScreen = true + }, + onNavigateToBiometric = { + showBiometricScreen = true + }, + viewModel = profileViewModel, + avatarRepository = avatarRepository, + dialogDao = RosettaDatabase.getDatabase(context).dialogDao(), + backgroundBlurColorId = backgroundBlurColorId + ) + } + // Other screens with swipe back SwipeBackContainer( isVisible = showBackupScreen, @@ -705,6 +761,33 @@ fun MainScreen( ) } + SwipeBackContainer( + isVisible = showAppearanceScreen, + onBack = { + showAppearanceScreen = false + showProfileScreen = true + }, + isDarkTheme = isDarkTheme + ) { + com.rosetta.messenger.ui.settings.AppearanceScreen( + isDarkTheme = isDarkTheme, + currentBlurColorId = backgroundBlurColorId, + onBack = { + showAppearanceScreen = false + showProfileScreen = true + }, + onBlurColorChange = { newId -> + mainScreenScope.launch { + prefsManager.setBackgroundBlurColorId(newId) + } + }, + onToggleTheme = onToggleTheme, + accountPublicKey = accountPublicKey, + accountName = accountName, + avatarRepository = avatarRepository + ) + } + SwipeBackContainer( isVisible = showUpdatesScreen, onBack = { showUpdatesScreen = false }, @@ -762,58 +845,6 @@ fun MainScreen( ) } - SwipeBackContainer( - isVisible = showProfileScreen, - onBack = { showProfileScreen = false }, - isDarkTheme = isDarkTheme - ) { - // Экран профиля - ProfileScreen( - isDarkTheme = isDarkTheme, - accountName = accountName, - accountUsername = accountUsername, - accountPublicKey = accountPublicKey, - accountPrivateKeyHash = privateKeyHash, - onBack = { showProfileScreen = false }, - onSaveProfile = { name, username -> - // Following desktop version pattern: - // 1. Server confirms save (handled in ProfileViewModel) - // 2. Local DB updated (handled in ProfileScreen LaunchedEffect) - // 3. This callback updates UI state immediately - accountName = name - accountUsername = username - // Reload account list so auth screen shows updated name - mainScreenScope.launch { - onAccountInfoUpdated() - } - }, - onLogout = onLogout, - onNavigateToTheme = { - showProfileScreen = false - showThemeScreen = true - }, - onNavigateToSafety = { - showProfileScreen = false - showSafetyScreen = true - }, - onNavigateToLogs = { - showProfileScreen = false - showLogsScreen = true - }, - onNavigateToCrashLogs = { - showProfileScreen = false - showCrashLogsScreen = true - }, - onNavigateToBiometric = { - showProfileScreen = false - showBiometricScreen = true - }, - viewModel = profileViewModel, - avatarRepository = avatarRepository, - dialogDao = RosettaDatabase.getDatabase(context).dialogDao() - ) - } - SwipeBackContainer( isVisible = showLogsScreen, onBack = { @@ -867,7 +898,8 @@ fun MainScreen( showOtherProfileScreen = false selectedOtherUser = null }, - avatarRepository = avatarRepository + avatarRepository = avatarRepository, + backgroundBlurColorId = backgroundBlurColorId ) } } diff --git a/app/src/main/java/com/rosetta/messenger/data/PreferencesManager.kt b/app/src/main/java/com/rosetta/messenger/data/PreferencesManager.kt index a6069e9..b025494 100644 --- a/app/src/main/java/com/rosetta/messenger/data/PreferencesManager.kt +++ b/app/src/main/java/com/rosetta/messenger/data/PreferencesManager.kt @@ -42,6 +42,9 @@ class PreferencesManager(private val context: Context) { // Language val APP_LANGUAGE = stringPreferencesKey("app_language") // "en", "ru", etc. + + // Appearance / Customization + val BACKGROUND_BLUR_COLOR_ID = stringPreferencesKey("background_blur_color_id") // id from BackgroundBlurPresets } // ═════════════════════════════════════════════════════════════ @@ -189,4 +192,17 @@ class PreferencesManager(private val context: Context) { suspend fun setAppLanguage(value: String) { context.dataStore.edit { preferences -> preferences[APP_LANGUAGE] = value } } + + // ═════════════════════════════════════════════════════════════ + // 🎨 APPEARANCE / CUSTOMIZATION + // ═════════════════════════════════════════════════════════════ + + val backgroundBlurColorId: Flow = + context.dataStore.data.map { preferences -> + preferences[BACKGROUND_BLUR_COLOR_ID] ?: "avatar" // Default: use avatar blur + } + + suspend fun setBackgroundBlurColorId(value: String) { + context.dataStore.edit { preferences -> preferences[BACKGROUND_BLUR_COLOR_ID] = value } + } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt index c168a34..804c28b 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt @@ -49,6 +49,7 @@ import com.rosetta.messenger.ui.components.AppleEmojiText import com.rosetta.messenger.ui.components.AvatarImage import com.rosetta.messenger.ui.components.BlurredAvatarBackground import com.rosetta.messenger.ui.components.VerifiedBadge +import com.rosetta.messenger.ui.settings.BackgroundBlurPresets import com.rosetta.messenger.ui.onboarding.PrimaryBlue import java.text.SimpleDateFormat import java.util.* @@ -167,6 +168,7 @@ fun ChatsListScreen( onSearchClick: () -> Unit, onNewChat: () -> Unit, onUserSelect: (com.rosetta.messenger.network.SearchUser) -> Unit = {}, + backgroundBlurColorId: String = "avatar", chatsViewModel: ChatsListViewModel = androidx.lifecycle.viewmodel.compose.viewModel(), avatarRepository: com.rosetta.messenger.repository.AvatarRepository? = null, onLogout: () -> Unit @@ -453,7 +455,8 @@ android.util.Log.d("ChatsListScreen", "✅ Total LaunchedEffect: ${System.curren avatarRepository = avatarRepository, fallbackColor = headerColor, blurRadius = 40f, - alpha = 0.6f + alpha = 0.6f, + overlayColors = BackgroundBlurPresets.getOverlayColors(backgroundBlurColorId) ) // Content поверх фона diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/BlurredAvatarBackground.kt b/app/src/main/java/com/rosetta/messenger/ui/components/BlurredAvatarBackground.kt index a6d49e4..16511fa 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/components/BlurredAvatarBackground.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/components/BlurredAvatarBackground.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.* import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.graphicsLayer @@ -28,6 +29,8 @@ import kotlinx.coroutines.withContext * @param fallbackColor Цвет фона если нет аватарки * @param blurRadius Радиус размытия (в пикселях) - применяется при обработке * @param alpha Прозрачность (0.0 - 1.0) + * @param overlayColors Опциональные цвета overlay поверх blur. Если null — стандартное поведение. + * Если 1 цвет — сплошной overlay, если 2+ — градиент. */ @Composable fun BoxScope.BlurredAvatarBackground( @@ -35,7 +38,8 @@ fun BoxScope.BlurredAvatarBackground( avatarRepository: AvatarRepository?, fallbackColor: Color, blurRadius: Float = 25f, - alpha: Float = 0.3f + alpha: Float = 0.3f, + overlayColors: List? = null ) { // Получаем аватары из репозитория val avatars by avatarRepository?.getAvatars(publicKey, allDecode = false)?.collectAsState() @@ -91,19 +95,55 @@ fun BoxScope.BlurredAvatarBackground( contentScale = ContentScale.Crop ) - // Дополнительный overlay для затемнения - Box( - modifier = Modifier - .fillMaxSize() - .background(fallbackColor.copy(alpha = 0.3f)) - ) + // Дополнительный overlay — кастомный или стандартный + if (overlayColors != null && overlayColors.isNotEmpty()) { + // Кастомный цветной overlay + val overlayModifier = if (overlayColors.size == 1) { + Modifier + .fillMaxSize() + .background(overlayColors[0].copy(alpha = 0.55f)) + } else { + Modifier + .fillMaxSize() + .background( + Brush.linearGradient( + colors = overlayColors.map { it.copy(alpha = 0.55f) } + ) + ) + } + Box(modifier = overlayModifier) + } else { + // Стандартный overlay для затемнения + Box( + modifier = Modifier + .fillMaxSize() + .background(fallbackColor.copy(alpha = 0.3f)) + ) + } } else { - // Fallback: цветной фон - Box( - modifier = Modifier - .fillMaxSize() - .background(fallbackColor) - ) + // Fallback: когда нет аватарки + if (overlayColors != null && overlayColors.isNotEmpty()) { + // Кастомный фон без blur + val bgModifier = if (overlayColors.size == 1) { + Modifier + .fillMaxSize() + .background(overlayColors[0]) + } else { + Modifier + .fillMaxSize() + .background( + Brush.linearGradient(colors = overlayColors) + ) + } + Box(modifier = bgModifier) + } else { + // Стандартный fallback: цветной фон + Box( + modifier = Modifier + .fillMaxSize() + .background(fallbackColor) + ) + } } } } 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 c5e6fe8..42c5300 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,31 +2,44 @@ 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 @@ -34,194 +47,309 @@ 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 kotlin.math.PI -import kotlin.math.cos +import androidx.compose.ui.unit.sp +import kotlin.math.abs import kotlin.math.max import kotlin.math.min +import kotlin.math.sqrt import androidx.compose.ui.graphics.Color as ComposeColor -// ═══════════════════════════════════════════════════════════════════════ -// Constants — exact match of Telegram's ProfileGooeyView -// ═══════════════════════════════════════════════════════════════════════ - +/** + * 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 { - 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 */ + // 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 = 10.dp // Extended from 18dp for smoother fade-out + + // 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.99f // Let opacity handle fade, don't cut off early + + // Blur settings for gooey effect (like Telegram's intensity = 15f) const val BLUR_RADIUS = 15f - const val BLUR_MIN = 2f - const val BLUR_MAX = 22f + + // 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 - val COLLAPSED_Y_OFFSET = (-29).dp + + // Y position offset when fully collapsed (Telegram: -dp(29)) + val COLLAPSED_Y_OFFSET = (-29).dp } -// ═══════════════════════════════════════════════════════════════════════ -// Alpha threshold color matrices — the core gooey trick -// ═══════════════════════════════════════════════════════════════════════ - /** - * GPU path: preserves RGB, thresholds alpha. - * newAlpha = oldAlpha × 51 − 6375 → cutoff at ~125/255 (≈49 %). - * Exact match of Telegram's GPUImpl filter. + * 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. */ 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 ) /** - * CPU path: forces RGB → 0 (black mask), steeper threshold. - * newAlpha = oldAlpha × 60 − 7500 → cutoff at ~125/255. - * Exact match of Telegram's CPUImpl filter. + * 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. */ -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 -// ═══════════════════════════════════════════════════════════════════════ -// Avatar state computation -// ═══════════════════════════════════════════════════════════════════════ - +/** + * 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 radius: Float, // vr in Telegram + val opacity: Float, // alpha / 255f val showBlob: Boolean, - 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 + 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, - @Suppress("UNUSED_PARAMETER") avatarSizeMinPx: Float, + avatarSizeExpandedPx: Float, // Normal avatar size (96dp in Telegram terms) + avatarSizeMinPx: Float, // Into notch size (24dp or notch width) hasAvatar: Boolean, - notchCenterX: Float, + // Notch info + notchCenterX: Float, // X position of front camera/notch notchCenterY: Float, notchRadiusPx: Float, - dp40: Float, dp34: Float, dp32: Float, dp18: Float, dp22: Float, - fullCornerRadius: 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 { + // ═══════════════════════════════════════════════════════════════ + // TELEGRAM LOGIC: diff = 1 - collapseProgress (for us) + // diff = how "expanded" the avatar is (1 = fully expanded, 0 = collapsed) + // ═══════════════════════════════════════════════════════════════ val diff = 1f - collapseProgress - - // Radius (vr) + + // ═══════════════════════════════════════════════════════════════ + // 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 && expansionProgress > 0f -> - lerpF(avatarSizeExpandedPx / 2f, screenWidthPx / 2f, expansionProgress) - collapseProgress > 0f -> { - val intoSize = notchRadiusPx * 2f - lerpF(intoSize, avatarSizeExpandedPx, diff) / 2f + // 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 -> { + // 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 } - + + // ═══════════════════════════════════════════════════════════════ + // Telegram thresholds + // ═══════════════════════════════════════════════════════════════ val isDrawing = radius <= dp40 - val isNear = radius <= dp32 - - // Center X + 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 centerX: Float = when { + // Pull-down expansion - stay at screen center hasAvatar && expansionProgress > 0f -> screenWidthPx / 2f - collapseProgress > 0f -> lerpF(notchCenterX, screenWidthPx / 2f, diff) + // Collapsing - animate X towards notch/camera + collapseProgress > 0f -> lerpFloat(endX, startX, diff) + // Normal state - screen center else -> screenWidthPx / 2f } - - // Center Y - val actionBarHeightPx = dp40 + dp18 // 56 dp in px - val avatarYOffset = dp22 // 21 dp ≈ 22 dp + + // ═══════════════════════════════════════════════════════════════ + // 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 val startY = statusBarHeightPx + actionBarHeightPx - avatarYOffset + (avatarSizeExpandedPx / 2f) - val endY = notchCenterY + val endY = notchCenterY // Target = notch center + val centerY: Float = when { - hasAvatar && expansionProgress > 0f -> - lerpF(startY, headerHeightPx / 2f, expansionProgress) - else -> lerpF(endY, startY, diff) + // Pull-down expansion + hasAvatar && expansionProgress > 0f -> { + lerpFloat(startY, headerHeightPx / 2f, expansionProgress) + } + // Collapsing - animate Y towards notch + else -> { + lerpFloat(endY, startY, diff) + } } - - // Corner radius + + // ═══════════════════════════════════════════════════════════════ + // CORNER RADIUS - Shape transition + // Telegram: аватарка остаётся КРУГЛОЙ пока тянешь! + // Квадратной становится только при полном раскрытии (isPulledDown) + // Переход круг→квадрат начинается при expansionProgress > 0.8 + // ═══════════════════════════════════════════════════════════════ val cornerRadius: Float = when { + // EXPANDED - переход к квадрату начинается только после 80% expansion expansionProgress > 0f -> { - val squareP = ((expansionProgress - 0.8f) / 0.2f).coerceIn(0f, 1f) - lerpF(fullCornerRadius, 0f, squareP) + // Telegram: квадрат только при полном раскрытии + // До 80% - остаётся круглым, после 80% - быстро переходит в квадрат + val squareProgress = ((expansionProgress - 0.8f) / 0.2f).coerceIn(0f, 1f) + lerpFloat(fullCornerRadius, 0f, squareProgress) } + // COLLAPSING - переход круг → rounded rect (like Telegram) isDrawing -> { - val shapeP = ((radius - dp34) / (dp40 - dp34)).coerceIn(0f, 1f) - val roundR = lerpF(dp22, fullCornerRadius, shapeP) - (radius / dp22) * roundR + // 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 — smooth alpha fade: 32 dp → 10 dp with ease-out curve + + // ═══════════════════════════════════════════════════════════════ + // OPACITY - Smooth fade: 32dp → 10dp with ease-out curve // Wider range + quadratic easing = much smoother disappearance - val opacity = if (isNear) { + // ═══════════════════════════════════════════════════════════════ + val opacity: Float = 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 + // Ease-out: starts fast, slows near end → avatar stays visible longer 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) - + lerpFloat(0f, 1f, eased) + } 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 - + + // 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, - contentBlurRadius = contentBlurRadius, - imageAlpha = imageAlpha + blurRadius = blurRadius ) } -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). -// ═══════════════════════════════════════════════════════════════════════ +private fun lerpFloat(start: Float, stop: Float, fraction: Float): Float { + return start + (stop - start) * fraction +} +/** + * 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( @@ -239,106 +367,159 @@ fun ProfileMetaballOverlay( 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() } - - // ── Notch ── + + // 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 notchInfo = remember { NotchInfoUtils.getInfo(context) } - val hasRealNotch = !MetaballDebug.forceNoNotch && - notchInfo != null && notchInfo.gravity == Gravity.CENTER && notchInfo.bounds.width() > 0 + + // 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 notchRadiusPx = remember(notchInfo) { - 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() } + 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() } + } } - val notchCX = remember(notchInfo, screenWidthPx) { - if (hasRealNotch && notchInfo != null) notchInfo.bounds.centerX() else screenWidthPx / 2f + + // 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 notchCY = remember(notchInfo) { - if (hasRealNotch && notchInfo != null && notchInfo.isLikelyCircle) + + val notchCenterY = remember(notchInfo) { + if (notchInfo != null && notchInfo.isLikelyCircle) { + // For circle: center is at bottom - width/2 (like Telegram) notchInfo.bounds.bottom - notchInfo.bounds.width() / 2f - else if (hasRealNotch && notchInfo != null) notchInfo.bounds.centerY() - else statusBarHPx / 2f + } else if (notchInfo != null) { + notchInfo.bounds.centerY() + } else { + statusBarHeightPx / 2f + } } - - // ── DP helpers ── + + // 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 dp7 = with(density) { 7.dp.toPx() } - - // ── Avatar state ── - val avatarState by remember( - screenWidthPx, statusBarHPx, headerHPx, notchCX, notchCY, notchRadiusPx - ) { + + // 🔥 FIX: Убраны volatile keys (collapseProgress, expansionProgress) из remember + // derivedStateOf автоматически отслеживает их как зависимости внутри лямбды + // Только стабильные параметры (размеры экрана, notch info) как ключи remember + val avatarState by remember(screenWidthPx, statusBarHeightPx, headerHeightPx, notchCenterX, notchCenterY, notchRadiusPx) { derivedStateOf { computeAvatarState( - collapseProgress, expansionProgress, - screenWidthPx, statusBarHPx, headerHPx, - avatarExpandPx, avatarMinPx, hasAvatar, - notchCX, notchCY, notchRadiusPx, - dp40, dp34, dp32, dp18, dp22, - avatarExpandPx / 2f + 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 ) } } - - // ── Connector ── + + // Path for metaball connector val metaballPath = remember { Path() } - val c1 = remember { Point() } - val c2 = remember { Point() } + val c1 = remember { Point() } // Notch center + val c2 = remember { Point() } // Avatar center + + // Reusable RectF like Telegram's AndroidUtilities.rectTmp val rectTmp = remember { RectF() } - 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 + // Black bar height for no-notch fallback (Telegram's BLACK_KING_BAR) + val blackBarHeightPx = with(density) { BLACK_BAR_HEIGHT_DP.dp.toPx() } - 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 + // 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 - // ═══════════════════════════════════════════════════════════ - // 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) + // Calculate "v" parameter - thickness of connector based on distance + val distance = avatarState.centerY - notchCenterY + val maxDist = avatarSizeExpandedPx + val c = (distance / maxDist).coerceIn(-1f, 1f) - Box(modifier = modifier.fillMaxSize().clip(RectangleShape)) { + 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 + } - // ───────────────────────────────────────────────────── - // LAYER 1 — gooey metaball shapes (blur + threshold) - // ───────────────────────────────────────────────────── - if (showMetaball) { + // 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) { Canvas( modifier = Modifier .fillMaxSize() .graphicsLayer { - renderEffect = RenderEffect - .createChainEffect( - RenderEffect.createColorFilterEffect( - ColorMatrixColorFilter(GPU_ALPHA_THRESHOLD_MATRIX) - ), - RenderEffect.createBlurEffect( - gooBlurRadius, gooBlurRadius, - Shader.TileMode.CLAMP - ) - ) + // 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) .asComposeRenderEffect() } ) { @@ -348,66 +529,310 @@ fun ProfileMetaballOverlay( } drawIntoCanvas { canvas -> - val nc = canvas.nativeCanvas + val nativeCanvas = canvas.nativeCanvas - // ── Notch / black bar target ── + // Draw target shape at top (notch or black bar fallback) if (showConnector) { - drawNotchShape(nc, hasRealNotch, notchInfo, notchCX, notchCY, - notchRadiusPx, screenWidthPx, blackBarPx, paint) - - // 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) + 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) } } - - // ── 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) - } + + // 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) } } } - } - - // ───────────────────────────────────────────────────── - // LAYER 2 — avatar content - // ───────────────────────────────────────────────────── + } + } // END if (showMetaballLayer) + + // ═══════════════════════════════════════════════════════════════ + // LAYER 2: Avatar content - UNIFIED BOX with SCALE + // 🔥 FIX: ОДИН Box для всех режимов - предотвращает мигание + // Базовый размер ФИКСИРОВАННЫЙ (baseSizeDp), изменение через graphicsLayer.scale + // ═══════════════════════════════════════════════════════════════ if (avatarState.showBlob) { - AvatarContentBox( - avatarState = avatarState, - expansionProgress = expansionProgress, - screenWidthPx = screenWidthPx, - headerHPx = headerHPx, - avatarExpandPx = avatarExpandPx, - applyContentBlur = expansionProgress == 0f && avatarState.contentBlurRadius > 0.5f, - avatarContent = avatarContent + // Базовый размер аватарки (ФИКСИРОВАННЫЙ для 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 ) } } } -// ═══════════════════════════════════════════════════════════════════════ -// 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!) -// ═══════════════════════════════════════════════════════════════════════ +/** + * 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, + 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 + ) + } + } +} +/** + * 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 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, @@ -424,87 +849,102 @@ fun ProfileMetaballOverlayCpu( 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 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() } - // ── Notch ── + // Get notch info (debug override supported) 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 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 { + kotlin.math.max(notchInfo.bounds.width(), notchInfo.bounds.height()) / 2f + } + } else { + with(density) { ProfileMetaballConstants.FALLBACK_CAMERA_SIZE.toPx() } + } } - val notchCX = remember(notchInfo, screenWidthPx) { + val notchCenterX = remember(notchInfo, screenWidthPx) { if (hasRealNotch && notchInfo != null) notchInfo.bounds.centerX() else screenWidthPx / 2f } - val notchCY = remember(notchInfo) { - if (hasRealNotch && notchInfo != null && notchInfo.isLikelyCircle) + val notchCenterY = remember(notchInfo) { + if (hasRealNotch && notchInfo != null && notchInfo.isLikelyCircle) { notchInfo.bounds.bottom - notchInfo.bounds.width() / 2f - else if (hasRealNotch && notchInfo != null) notchInfo.bounds.centerY() - else blackBarPx / 2f + } else if (hasRealNotch && notchInfo != null) { + notchInfo.bounds.centerY() + } else { + blackBarHeightPx / 2f + } } - // ── DP helpers ── + // Thresholds 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() } - // ── Avatar state ── - val avatarState by remember( - screenWidthPx, statusBarHPx, headerHPx, notchCX, notchCY, notchRadiusPx - ) { + val avatarState by remember(screenWidthPx, statusBarHeightPx, headerHeightPx, notchCenterX, notchCenterY, notchRadiusPx) { derivedStateOf { computeAvatarState( - collapseProgress, expansionProgress, - screenWidthPx, statusBarHPx, headerHPx, - avatarExpandPx, avatarMinPx, hasAvatar, - notchCX, notchCY, notchRadiusPx, - dp40, dp34, dp32, dp18, dp22, - avatarExpandPx / 2f + 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 ) } } - // ── Connector ── - val metaballPath = remember { Path() } + // Reusable objects + val metaballPath = remember { android.graphics.Path() } val c1 = remember { Point() } val c2 = remember { Point() } val rectTmp = remember { RectF() } - val dist = avatarState.centerY - notchCY - val maxDist = avatarExpandPx - val cParam = (dist / maxDist).coerceIn(-1f, 1f) + // Connector calculations + val distance = avatarState.centerY - notchCenterY + val maxDist = avatarSizeExpandedPx + val cParam = (distance / 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 + 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 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 + val showConnector = hasAvatar && expansionProgress == 0f && avatarState.isDrawing && avatarState.showBlob && distance < maxDist * 1.5f + val showMetaballLayer = hasAvatar && expansionProgress == 0f - // ── Offscreen bitmap ── + // CPU-specific: downscaled bitmap for blur + // Reduced from 5 to 3 for higher resolution — smoother edges after alpha threshold val scaleConst = 3f - 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 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 offBitmap = remember(bmpW, bmpH) { - android.graphics.Bitmap.createBitmap(bmpW, bmpH, android.graphics.Bitmap.Config.ARGB_8888) + val offscreenBitmap = remember(bitmapW, bitmapH) { + android.graphics.Bitmap.createBitmap(bitmapW, bitmapH, android.graphics.Bitmap.Config.ARGB_8888) } - val offCanvas = remember(offBitmap) { android.graphics.Canvas(offBitmap) } + val offscreenCanvas = remember(offscreenBitmap) { + android.graphics.Canvas(offscreenBitmap) + } + + // Reusable paints val blackPaint = remember { android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply { color = android.graphics.Color.BLACK @@ -512,319 +952,294 @@ 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) } } - 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) + + // Cleanup bitmap on dispose + DisposableEffect(offscreenBitmap) { + onDispose { + if (!offscreenBitmap.isRecycled) { + offscreenBitmap.recycle() + } } } - DisposableEffect(offBitmap) { - onDispose { if (!offBitmap.isRecycled) offBitmap.recycle() } - } - - Box(modifier = modifier.fillMaxSize().clip(RectangleShape)) { - - // ───────────────────────────────────────────────────── - // LAYER 1 — CPU gooey shapes - // ───────────────────────────────────────────────────── - if (showMetaball) { + Box(modifier = modifier + .fillMaxSize() + .clip(RectangleShape) + ) { + // LAYER 1: CPU-rendered metaball shapes + if (showMetaballLayer) { Canvas(modifier = Modifier.fillMaxSize()) { drawIntoCanvas { canvas -> - val nc = canvas.nativeCanvas - val offX = (screenWidthPx - optW) / 2f + val nativeCanvas = canvas.nativeCanvas + val optimizedOffsetX = (screenWidthPx - optimizedW) / 2f - offBitmap.eraseColor(0) - offCanvas.save() - offCanvas.scale( - offBitmap.width.toFloat() / optW, - offBitmap.height.toFloat() / optH + // Clear offscreen bitmap + offscreenBitmap.eraseColor(0) + + // Draw shapes into downscaled bitmap + offscreenCanvas.save() + offscreenCanvas.scale( + offscreenBitmap.width.toFloat() / optimizedW, + offscreenBitmap.height.toFloat() / optimizedH ) - offCanvas.translate(-offX, 0f) + offscreenCanvas.translate(-optimizedOffsetX, 0f) - // Notch / black bar + // Draw target (notch or black bar) if (showConnector) { - drawNotchShape(offCanvas, hasRealNotch, notchInfo, notchCX, notchCY, - notchRadiusPx, screenWidthPx, blackBarPx, blackPaint) - drawDripDown(offCanvas, notchCX, notchCY, notchRadiusPx, - dp7 * 3f, collapseProgress, blackPaint) + 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) + } } - // Avatar shape + // Draw avatar shape if (avatarState.showBlob) { - drawAvatarShape(offCanvas, avatarState, rectTmp, blackPaint) - if (showConnector) { - drawDripUp(offCanvas, avatarState, dp7 * 3f, - collapseProgress, blackPaint) + 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) } } - // Connector + // Draw metaball connector if (showConnector && avatarState.showBlob) { - c1.x = notchCX; c1.y = notchCY - c2.x = avatarState.centerX; c2.y = avatarState.centerY + c1.x = notchCenterX + c1.y = notchCenterY + c2.x = avatarState.centerX + c2.y = avatarState.centerY + if (createMetaballPath(c1, c2, notchRadiusPx, avatarState.radius, v, metaballPath)) { - offCanvas.drawPath(metaballPath, blackPaint) + offscreenCanvas.drawPath(metaballPath, blackPaint) } } - offCanvas.restore() + offscreenCanvas.restore() - // ── Stack blur — CONSTANT radius ── - val blurRadius = (ProfileMetaballConstants.BLUR_RADIUS * 2 / scaleConst) - .toInt().coerceAtLeast(1) - stackBlurBitmapInPlace(offBitmap, blurRadius) + // Apply stack blur on CPU + val blurRadius = (ProfileMetaballConstants.BLUR_RADIUS * 2 / scaleConst).toInt().coerceAtLeast(1) + stackBlurBitmapInPlace(offscreenBitmap, blurRadius) - // ── Draw with threshold filter ── - nc.save() - nc.translate(offX, 0f) - nc.scale( - optW.toFloat() / offBitmap.width, - optH.toFloat() / offBitmap.height + // 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 ) - nc.drawBitmap(offBitmap, 0f, 0f, filterPaint) - nc.drawBitmap(offBitmap, 0f, 0f, srcAtopPaint) - nc.restore() + nativeCanvas.drawBitmap(offscreenBitmap, 0f, 0f, filterPaint) + nativeCanvas.restore() } } } - // ───────────────────────────────────────────────────── - // LAYER 2 — avatar content - // ───────────────────────────────────────────────────── + // LAYER 2: Avatar content (same as GPU path) if (avatarState.showBlob) { - AvatarContentBox( - avatarState = avatarState, - expansionProgress = expansionProgress, - screenWidthPx = screenWidthPx, - headerHPx = headerHPx, - avatarExpandPx = avatarExpandPx, - applyContentBlur = false, // No RenderEffect on CPU path - avatarContent = avatarContent - ) - } - } -} + val baseSizeDp = ProfileMetaballConstants.AVATAR_SIZE_EXPANDED + val baseSizePx = with(density) { baseSizeDp.toPx() } -// ═══════════════════════════════════════════════════════════════════════ -// COMPAT PATH — no metaball, just position / scale / opacity -// ═══════════════════════════════════════════════════════════════════════ + val baseScale = (avatarState.radius * 2f) / baseSizePx + val targetExpansionScale = screenWidthPx / baseSizePx + val uniformScale = if (expansionProgress > 0f) { + lerpFloat(baseScale, targetExpansionScale, expansionProgress) + } else { + baseScale + }.coerceAtLeast(0.01f) -@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 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 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 - ) - } - } -} - -// ═══════════════════════════════════════════════════════════════════════ -// Shared helpers -// ═══════════════════════════════════════════════════════════════════════ - -/** Avatar content Box — shared between all three paths. */ -@Composable -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 baseScale = (avatarState.radius * 2f) / baseSizePx - val targetScale = screenWidthPx / baseSizePx - val scale = (if (expansionProgress > 0f) lerpF(baseScale, targetScale, expansionProgress) - else baseScale).coerceAtLeast(0.01f) - - 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 - - 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 - ) - .asComposeRenderEffect() - } + val cornerRadiusPx: Float = if (expansionProgress > 0f) { + lerpFloat(baseSizePx / 2f, 0f, expansionProgress) + } else { + (avatarState.cornerRadius / baseScale).coerceAtMost(baseSizePx / 2f) } - .clip(RoundedCornerShape(cornerDp)), - contentAlignment = Alignment.Center, - content = avatarContent - ) -} -// ── Shape drawing helpers ── + val offsetX = with(density) { (currentCenterX - baseSizePx / 2f).toDp() } + val offsetY = with(density) { (currentCenterY - baseSizePx / 2f).toDp() } + val cornerRadiusDp = with(density) { cornerRadiusPx.toDp() } -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) + 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 + ) + } } } -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 -// ═══════════════════════════════════════════════════════════════════════ - +/** + * 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 - var forceNoNotch: Boolean = false + var forceMode: String? = null // "gpu", "cpu", "compat", or null + var forceNoNotch: Boolean = false // true = pretend no notch exists } +/** + * 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! + */ +@Composable +fun MetaballDebugPanel(modifier: Modifier = Modifier) { + var currentMode by remember { mutableStateOf(MetaballDebug.forceMode) } + var noNotch by remember { mutableStateOf(MetaballDebug.forceNoNotch) } + + val context = LocalContext.current + val perfClass = remember { DevicePerformanceClass.get(context) } + + 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 + ) + + // 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) + ) + .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 + ) + } + } + } + + // 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) + ) + ) + } + + // 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 + ) + } +} + +/** + * 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 + */ @Composable fun ProfileMetaballEffect( collapseProgress: Float, @@ -837,43 +1252,75 @@ fun ProfileMetaballEffect( avatarContent: @Composable BoxScope.() -> Unit = {}, ) { val context = LocalContext.current - val perfClass = remember { DevicePerformanceClass.get(context) } + val performanceClass = 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 - "cpu" -> false + "gpu" -> Build.VERSION.SDK_INT >= Build.VERSION_CODES.S // still need API 31 + "cpu" -> false "compat" -> false - else -> Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && - perfClass >= PerformanceClass.AVERAGE + else -> Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && performanceClass >= PerformanceClass.AVERAGE } val useCpu = when (MetaballDebug.forceMode) { - "gpu" -> false - "cpu" -> true + "gpu" -> false + "cpu" -> true "compat" -> false - else -> !useGpu && perfClass >= PerformanceClass.HIGH + else -> !useGpu && performanceClass >= PerformanceClass.HIGH } when { useGpu -> { - val factorMult = if (perfClass == PerformanceClass.HIGH) 1f else 1.5f + val factorMult = if (performanceClass == PerformanceClass.HIGH) 1f else 1.5f ProfileMetaballOverlay( - collapseProgress, expansionProgress, - statusBarHeight, headerHeight, - hasAvatar, avatarColor, factorMult, - modifier, avatarContent + 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 ) } - 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/AppearanceColors.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/AppearanceColors.kt new file mode 100644 index 0000000..44ad56c --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/AppearanceColors.kt @@ -0,0 +1,75 @@ +package com.rosetta.messenger.ui.settings + +import androidx.compose.ui.graphics.Color + +/** + * Модель для варианта фона blur в профиле + * @param id Уникальный идентификатор (сохраняется в PreferencesManager) + * @param colors Список цветов. Если 1 — сплошной цвет, если 2+ — градиент + * @param label Человекочитаемое название + */ +data class BackgroundBlurOption( + val id: String, + val colors: List, + val label: String +) + +/** + * Предустановленные варианты цвета/градиента для background blur в профиле. + * Референс: сетка цветных кружков как в desktop-версии. + */ +object BackgroundBlurPresets { + + /** Вариант "по умолчанию" — используется blur аватарки без цветного overlay */ + val avatarDefault = BackgroundBlurOption( + id = "avatar", + colors = emptyList(), + label = "Avatar" + ) + + /** Сплошные цвета */ + private val solidColors = listOf( + BackgroundBlurOption("solid_blue", listOf(Color(0xFF2979FF)), "Blue"), + BackgroundBlurOption("solid_green", listOf(Color(0xFF4CAF50)), "Green"), + BackgroundBlurOption("solid_orange", listOf(Color(0xFFFF9800)), "Orange"), + BackgroundBlurOption("solid_red", listOf(Color(0xFFE53935)), "Red"), + BackgroundBlurOption("solid_purple", listOf(Color(0xFF7C4DFF)), "Purple"), + BackgroundBlurOption("solid_teal", listOf(Color(0xFF009688)), "Teal"), + BackgroundBlurOption("solid_pink", listOf(Color(0xFFE91E63)), "Pink"), + BackgroundBlurOption("solid_grey", listOf(Color(0xFF78909C)), "Grey"), + ) + + /** Градиенты (по 2 цвета, как на референсе) */ + private val gradients = listOf( + BackgroundBlurOption("grad_blue_cyan", listOf(Color(0xFF2979FF), Color(0xFF00BCD4)), "Blue|Cyan"), + BackgroundBlurOption("grad_green_lime", listOf(Color(0xFF4CAF50), Color(0xFFCDDC39)), "Green|Lime"), + BackgroundBlurOption("grad_orange_yellow", listOf(Color(0xFFFF9800), Color(0xFFFFEB3B)), "Orange|Yellow"), + BackgroundBlurOption("grad_red_pink", listOf(Color(0xFFE53935), Color(0xFFFF4081)), "Red|Pink"), + BackgroundBlurOption("grad_purple_blue", listOf(Color(0xFF7C4DFF), Color(0xFF536DFE)), "Purple|Blue"), + BackgroundBlurOption("grad_teal_green", listOf(Color(0xFF009688), Color(0xFF69F0AE)), "Teal|Green"), + BackgroundBlurOption("grad_pink_magenta", listOf(Color(0xFFE91E63), Color(0xFFCE93D8)), "Pink|Magenta"), + BackgroundBlurOption("grad_mono", listOf(Color(0xFF546E7A), Color(0xFFB0BEC5)), "Mono"), + ) + + /** Все варианты в порядке отображения: сначала сплошные, потом градиенты */ + val all: List = solidColors + gradients + + /** Все варианты включая "Avatar" (default) */ + val allWithDefault: List = listOf(avatarDefault) + all + + /** + * Найти вариант по id. Возвращает [avatarDefault] если не найден. + */ + fun findById(id: String): BackgroundBlurOption { + return allWithDefault.find { it.id == id } ?: avatarDefault + } + + /** + * Получить список цветов для overlay по id. + * Возвращает null если id == "avatar" (значит используется blur аватарки без overlay). + */ + fun getOverlayColors(id: String): List? { + val option = findById(id) + return if (option.colors.isEmpty()) null else option.colors + } +} diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/AppearanceScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/AppearanceScreen.kt new file mode 100644 index 0000000..4047dcb --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/AppearanceScreen.kt @@ -0,0 +1,573 @@ +package com.rosetta.messenger.ui.settings + +import android.graphics.Bitmap +import androidx.activity.compose.BackHandler +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import compose.icons.TablerIcons +import compose.icons.tablericons.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.rosetta.messenger.repository.AvatarRepository +import com.rosetta.messenger.utils.AvatarFileManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import compose.icons.tablericons.Sun +import compose.icons.tablericons.Moon + +/** + * Экран кастомизации внешнего вида. + * Позволяет выбрать цвет/градиент для background blur в профиле. + * Preview повторяет avatar block из профиля — реальный blur + аватарка. + */ +@Composable +fun AppearanceScreen( + isDarkTheme: Boolean, + currentBlurColorId: String, + onBack: () -> Unit, + onBlurColorChange: (String) -> Unit, + onToggleTheme: () -> Unit = {}, + accountPublicKey: String = "", + accountName: String = "", + avatarRepository: AvatarRepository? = null +) { + val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF) + val textColor = if (isDarkTheme) Color.White else Color.Black + val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) + + var selectedId by remember { mutableStateOf(currentBlurColorId) } + + BackHandler { onBack() } + + Column( + modifier = Modifier + .fillMaxSize() + .background(backgroundColor) + ) { + // ═══════════════════════════════════════════════════════════ + // CONTENT + // ═══════════════════════════════════════════════════════════ + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) { + // ═══════════════════════════════════════════════════════ + // LIVE PREVIEW — реальный avatar block из профиля + // Покрывает status bar, кнопки overlay поверх + // ═══════════════════════════════════════════════════════ + Box { + ProfileBlurPreview( + selectedId = selectedId, + isDarkTheme = isDarkTheme, + publicKey = accountPublicKey, + displayName = accountName, + avatarRepository = avatarRepository + ) + + // Overlay: кнопка назад (слева) и смена темы (справа) + Row( + modifier = Modifier + .fillMaxWidth() + .statusBarsPadding() + .padding(horizontal = 4.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + IconButton(onClick = onBack) { + Icon( + imageVector = TablerIcons.ArrowLeft, + contentDescription = "Back", + tint = Color.White + ) + } + IconButton(onClick = onToggleTheme) { + Icon( + imageVector = if (isDarkTheme) TablerIcons.Sun else TablerIcons.Moon, + contentDescription = "Toggle theme", + tint = Color.White + ) + } + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + // ═══════════════════════════════════════════════════════ + // SECTION TITLE + // ═══════════════════════════════════════════════════════ + Text( + text = "BACKGROUND COLOR", + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + color = secondaryTextColor, + letterSpacing = 0.5.sp, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + ) + + // ═══════════════════════════════════════════════════════ + // COLOR GRID + // ═══════════════════════════════════════════════════════ + ColorSelectionGrid( + selectedId = selectedId, + isDarkTheme = isDarkTheme, + onSelect = { id -> + selectedId = id + onBlurColorChange(id) + } + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "Choose a color or gradient overlay for the blurred header background in your profile.", + fontSize = 13.sp, + color = secondaryTextColor, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + lineHeight = 18.sp + ) + + Spacer(modifier = Modifier.height(32.dp)) + } + } +} + + +// ═══════════════════════════════════════════════════════════════════════ +// 🎨 LIVE PREVIEW — повторяет avatar block из ProfileScreen +// Реальный blur аватарки + overlay + круглая аватарка + имя +// ═══════════════════════════════════════════════════════════════════════ + +@Composable +private fun ProfileBlurPreview( + selectedId: String, + isDarkTheme: Boolean, + publicKey: String, + displayName: String, + avatarRepository: AvatarRepository? +) { + val option = BackgroundBlurPresets.findById(selectedId) + val overlayColors = BackgroundBlurPresets.getOverlayColors(selectedId) + val avatarColors = getAvatarColor(publicKey, isDarkTheme) + + // Загрузка аватарки + val avatars by avatarRepository?.getAvatars(publicKey, allDecode = false)?.collectAsState() + ?: remember { mutableStateOf(emptyList()) } + + val avatarKey = remember(avatars) { avatars.firstOrNull()?.timestamp ?: 0L } + var avatarBitmap by remember { mutableStateOf(null) } + var blurredBitmap by remember { mutableStateOf(null) } + + LaunchedEffect(avatarKey) { + val current = avatars + if (current.isNotEmpty()) { + val decoded = withContext(Dispatchers.IO) { + AvatarFileManager.base64ToBitmap(current.first().base64Data) + } + if (decoded != null) { + avatarBitmap = decoded + // Blur для фонового изображения + blurredBitmap = withContext(Dispatchers.Default) { + val scaled = Bitmap.createScaledBitmap( + decoded, + decoded.width / 4, + decoded.height / 4, + true + ) + var result = scaled + repeat(3) { + result = fastBlur(result, 6) + } + result + } + } + } else { + avatarBitmap = null + blurredBitmap = null + } + } + + // Анимированный label + val labelText = if (option.colors.isEmpty()) "Avatar Blur" else option.label + + // Preview — повторяет profile header, покрывает status bar + val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + Box( + modifier = Modifier + .fillMaxWidth() + .height(280.dp + statusBarHeight) + ) { + // ═══════════════════════════════════════════════════ + // LAYER 1: Blurred avatar background (как в профиле) + // ═══════════════════════════════════════════════════ + if (blurredBitmap != null) { + Image( + bitmap = blurredBitmap!!.asImageBitmap(), + contentDescription = null, + modifier = Modifier + .fillMaxSize() + .graphicsLayer { alpha = 0.35f }, + contentScale = ContentScale.Crop + ) + } + + // ═══════════════════════════════════════════════════ + // LAYER 2: Color/gradient overlay (или fallback) + // ═══════════════════════════════════════════════════ + if (overlayColors != null && overlayColors.isNotEmpty()) { + val overlayMod = if (overlayColors.size == 1) { + Modifier + .fillMaxSize() + .background(overlayColors[0].copy(alpha = if (blurredBitmap != null) 0.55f else 0.85f)) + } else { + Modifier + .fillMaxSize() + .background( + Brush.linearGradient( + colors = overlayColors.map { + it.copy(alpha = if (blurredBitmap != null) 0.55f else 0.85f) + } + ) + ) + } + Box(modifier = overlayMod) + } else if (blurredBitmap != null) { + // Стандартный затемняющий overlay (как в профиле) + Box( + modifier = Modifier + .fillMaxSize() + .background(avatarColors.backgroundColor.copy(alpha = 0.3f)) + ) + } else { + // Нет аватарки и нет overlay — fallback цвет + Box( + modifier = Modifier + .fillMaxSize() + .background( + if (isDarkTheme) Color(0xFF2A2A2E) else Color(0xFFD8D8DC) + ) + ) + } + + // ═══════════════════════════════════════════════════ + // LAYER 3: Тонкий нижний градиент-затемнение + // ═══════════════════════════════════════════════════ + Box( + modifier = Modifier + .fillMaxWidth() + .height(80.dp) + .align(Alignment.BottomCenter) + .background( + Brush.verticalGradient( + colors = listOf( + Color.Transparent, + Color.Black.copy(alpha = 0.35f) + ) + ) + ) + ) + + // ═══════════════════════════════════════════════════ + // LAYER 4: Avatar circle + Name + subtitle + // Повторяет layout из CollapsingProfileHeader + // ═══════════════════════════════════════════════════ + Column( + modifier = Modifier + .fillMaxSize() + .padding(top = statusBarHeight) + .padding(bottom = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + // Круглая аватарка с shadow — 120dp как в ProfileScreen + Box( + modifier = Modifier + .size(120.dp) + .shadow(12.dp, CircleShape) + .clip(CircleShape) + .background(avatarColors.backgroundColor), + contentAlignment = Alignment.Center + ) { + if (avatarBitmap != null) { + Image( + bitmap = avatarBitmap!!.asImageBitmap(), + contentDescription = "Avatar", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } else { + // Placeholder с инициалами (40sp как в ProfileScreen) + Text( + text = getInitials(displayName.ifBlank { publicKey.take(6) }), + fontSize = 40.sp, + fontWeight = FontWeight.Bold, + color = avatarColors.textColor + ) + } + } + + Spacer(modifier = Modifier.height(14.dp)) + + // Имя пользователя + Text( + text = displayName.ifBlank { publicKey.take(10) + "..." }, + fontSize = 24.sp, + fontWeight = FontWeight.SemiBold, + color = Color.White, + textAlign = TextAlign.Center, + maxLines = 1 + ) + + Spacer(modifier = Modifier.height(2.dp)) + + // Label текущего пресета — с иконкой стрелки для градиентов + val labelParts = labelText.split("|") + if (labelParts.size == 2) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Text( + text = labelParts[0], + fontSize = 14.sp, + color = Color.White.copy(alpha = 0.7f) + ) + Icon( + imageVector = TablerIcons.ArrowNarrowRight, + contentDescription = null, + tint = Color.White.copy(alpha = 0.5f), + modifier = Modifier + .padding(horizontal = 4.dp) + .size(16.dp) + ) + Text( + text = labelParts[1], + fontSize = 14.sp, + color = Color.White.copy(alpha = 0.7f) + ) + } + } else { + Text( + text = labelText, + fontSize = 14.sp, + color = Color.White.copy(alpha = 0.7f), + textAlign = TextAlign.Center + ) + } + } + } + } + + +// ═══════════════════════════════════════════════════════════════════ +// 🎨 COLOR GRID — сетка выбора цветов (8 в ряду) +// ═══════════════════════════════════════════════════════════════════ + +@Composable +private fun ColorSelectionGrid( + selectedId: String, + isDarkTheme: Boolean, + onSelect: (String) -> Unit +) { + val allOptions = BackgroundBlurPresets.allWithDefault + val columns = 8 + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp) + ) { + allOptions.chunked(columns).forEach { rowItems -> + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 6.dp), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + rowItems.forEach { option -> + ColorCircleItem( + option = option, + isSelected = option.id == selectedId, + isDarkTheme = isDarkTheme, + onClick = { onSelect(option.id) } + ) + } + repeat(columns - rowItems.size) { + Spacer(modifier = Modifier.size(40.dp)) + } + } + } + } +} + +@Composable +private fun ColorCircleItem( + option: BackgroundBlurOption, + isSelected: Boolean, + isDarkTheme: Boolean, + onClick: () -> Unit +) { + val scale by animateFloatAsState( + targetValue = if (isSelected) 1.15f else 1.0f, + animationSpec = tween(200), + label = "scale" + ) + + val borderColor by animateColorAsState( + targetValue = if (isSelected) { + if (isDarkTheme) Color.White else Color(0xFF222222) + } else { + Color.Transparent + }, + animationSpec = tween(200), + label = "border" + ) + + Box( + modifier = Modifier + .size(40.dp) + .scale(scale) + .clip(CircleShape) + .border( + width = if (isSelected) 2.5.dp else 0.5.dp, + color = if (isSelected) borderColor else Color.White.copy(alpha = 0.12f), + shape = CircleShape + ) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center + ) { + when { + option.id == "avatar" -> { + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.linearGradient( + colors = listOf(Color(0xFF3A3A3C), Color(0xFF8E8E93)) + ) + ), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = TablerIcons.CircleOff, + contentDescription = "Default", + tint = Color.White.copy(alpha = 0.9f), + modifier = Modifier.size(18.dp) + ) + } + } + option.colors.size == 1 -> { + Box( + modifier = Modifier + .fillMaxSize() + .background(option.colors[0]) + ) + } + option.colors.size >= 2 -> { + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.linearGradient(colors = option.colors) + ) + ) + } + } + + // Галочка с затемнённым фоном + if (isSelected) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.25f)), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = TablerIcons.Check, + contentDescription = "Selected", + tint = Color.White, + modifier = Modifier.size(18.dp) + ) + } + } + } +} + +/** + * Быстрый box blur (для preview, идентично BlurredAvatarBackground) + */ +private fun fastBlur(source: Bitmap, radius: Int): Bitmap { + if (radius < 1) return source + val w = source.width + val h = source.height + val bitmap = source.copy(source.config, true) + val pixels = IntArray(w * h) + bitmap.getPixels(pixels, 0, w, 0, 0, w, h) + for (y in 0 until h) blurRow(pixels, y, w, radius) + for (x in 0 until w) blurColumn(pixels, x, w, h, radius) + bitmap.setPixels(pixels, 0, w, 0, 0, w, h) + return bitmap +} + +private fun blurRow(pixels: IntArray, y: Int, w: Int, radius: Int) { + var sR = 0; var sG = 0; var sB = 0; var sA = 0 + val dv = radius * 2 + 1; val off = y * w + for (i in -radius..radius) { + val x = i.coerceIn(0, w - 1); val p = pixels[off + x] + sA += (p shr 24) and 0xff; sR += (p shr 16) and 0xff + sG += (p shr 8) and 0xff; sB += p and 0xff + } + for (x in 0 until w) { + pixels[off + x] = ((sA / dv) shl 24) or ((sR / dv) shl 16) or ((sG / dv) shl 8) or (sB / dv) + val xL = (x - radius).coerceIn(0, w - 1); val xR = (x + radius + 1).coerceIn(0, w - 1) + val lp = pixels[off + xL]; val rp = pixels[off + xR] + sA += ((rp shr 24) and 0xff) - ((lp shr 24) and 0xff) + sR += ((rp shr 16) and 0xff) - ((lp shr 16) and 0xff) + sG += ((rp shr 8) and 0xff) - ((lp shr 8) and 0xff) + sB += (rp and 0xff) - (lp and 0xff) + } +} + +private fun blurColumn(pixels: IntArray, x: Int, w: Int, h: Int, radius: Int) { + var sR = 0; var sG = 0; var sB = 0; var sA = 0 + val dv = radius * 2 + 1 + for (i in -radius..radius) { + val y = i.coerceIn(0, h - 1); val p = pixels[y * w + x] + sA += (p shr 24) and 0xff; sR += (p shr 16) and 0xff + sG += (p shr 8) and 0xff; sB += p and 0xff + } + for (y in 0 until h) { + val off = y * w + x + pixels[off] = ((sA / dv) shl 24) or ((sR / dv) shl 16) or ((sG / dv) shl 8) or (sB / dv) + val yT = (y - radius).coerceIn(0, h - 1); val yB = (y + radius + 1).coerceIn(0, h - 1) + val tp = pixels[yT * w + x]; val bp = pixels[yB * w + x] + sA += ((bp shr 24) and 0xff) - ((tp shr 24) and 0xff) + sR += ((bp shr 16) and 0xff) - ((tp shr 16) and 0xff) + sG += ((bp shr 8) and 0xff) - ((tp shr 8) and 0xff) + sB += (bp and 0xff) - (tp and 0xff) + } +} diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt index 9e3a154..8e75f9d 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt @@ -110,7 +110,8 @@ fun OtherProfileScreen( user: SearchUser, isDarkTheme: Boolean, onBack: () -> Unit, - avatarRepository: AvatarRepository? = null + avatarRepository: AvatarRepository? = null, + backgroundBlurColorId: String = "avatar" ) { var isBlocked by remember { mutableStateOf(false) } var showAvatarMenu by remember { mutableStateOf(false) } @@ -457,7 +458,8 @@ fun OtherProfileScreen( } catch (e: Exception) { } } - } + }, + backgroundBlurColorId = backgroundBlurColorId ) } } @@ -483,7 +485,8 @@ private fun CollapsingOtherProfileHeader( isBlocked: Boolean, onBlockToggle: () -> Unit, avatarRepository: AvatarRepository? = null, - onClearChat: () -> Unit + onClearChat: () -> Unit, + backgroundBlurColorId: String = "avatar" ) { val density = LocalDensity.current @@ -521,7 +524,8 @@ private fun CollapsingOtherProfileHeader( avatarRepository = avatarRepository, fallbackColor = avatarColors.backgroundColor, blurRadius = 25f, - alpha = 0.3f + alpha = 0.3f, + overlayColors = com.rosetta.messenger.ui.settings.BackgroundBlurPresets.getOverlayColors(backgroundBlurColorId) ) // ═══════════════════════════════════════════════════════════ 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 c999d6f..caec63a 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 @@ -189,13 +189,15 @@ fun ProfileScreen( onSaveProfile: (name: String, username: String) -> Unit, onLogout: () -> Unit, onNavigateToTheme: () -> Unit = {}, + onNavigateToAppearance: () -> Unit = {}, onNavigateToSafety: () -> Unit = {}, onNavigateToLogs: () -> Unit = {}, onNavigateToCrashLogs: () -> Unit = {}, onNavigateToBiometric: () -> Unit = {}, viewModel: ProfileViewModel = androidx.lifecycle.viewmodel.compose.viewModel(), avatarRepository: AvatarRepository? = null, - dialogDao: com.rosetta.messenger.database.DialogDao? = null + dialogDao: com.rosetta.messenger.database.DialogDao? = null, + backgroundBlurColorId: String = "avatar" ) { val context = LocalContext.current val activity = context as? FragmentActivity @@ -633,6 +635,14 @@ fun ProfileScreen( showDivider = true ) + TelegramSettingsItem( + icon = TablerIcons.Brush, + title = "Appearance", + onClick = onNavigateToAppearance, + isDarkTheme = isDarkTheme, + showDivider = true + ) + TelegramSettingsItem( icon = TablerIcons.Lock, title = "Safety", @@ -716,7 +726,8 @@ fun ProfileScreen( } }, hasAvatar = hasAvatar, - avatarRepository = avatarRepository + avatarRepository = avatarRepository, + backgroundBlurColorId = backgroundBlurColorId ) } @@ -757,7 +768,8 @@ private fun CollapsingProfileHeader( onSetPhotoClick: () -> Unit, onDeletePhotoClick: () -> Unit, hasAvatar: Boolean, - avatarRepository: AvatarRepository? + avatarRepository: AvatarRepository?, + backgroundBlurColorId: String = "avatar" ) { @Suppress("UNUSED_VARIABLE") val density = LocalDensity.current @@ -808,7 +820,8 @@ private fun CollapsingProfileHeader( avatarRepository = avatarRepository, fallbackColor = avatarColors.backgroundColor, blurRadius = 25f, - alpha = 0.3f + alpha = 0.3f, + overlayColors = BackgroundBlurPresets.getOverlayColors(backgroundBlurColorId) ) // ═══════════════════════════════════════════════════════════