Add new drawable resources for icons and themes

- Created `archive_filled.xml` for filled archive icon.
- Added `bookmark_outlined.xml` for outlined bookmark icon.
- Introduced `day_theme_filled.xml` for day theme icon.
- Added `folder_outlined.xml` for outlined folder icon.
- Created `gear_outlined.xml` for outlined gear icon.
- Introduced `night_mode.xml` for night mode icon.
This commit is contained in:
2026-02-13 17:37:03 +05:00
parent e17b03c1c5
commit 93ce53d3d5
30 changed files with 1269 additions and 147 deletions

View File

@@ -415,6 +415,16 @@ class MainActivity : FragmentActivity() {
publicKey = acc.publicKey
)
}
},
onSwitchAccount = { targetPublicKey ->
// Switch to another account: logout current, then auto-login target
currentAccount = null
scope.launch {
com.rosetta.messenger.network.ProtocolManager.disconnect()
accountManager.logout()
// Set the target account as last logged so UnlockScreen picks it up
accountManager.setLastLoggedPublicKey(targetPublicKey)
}
}
)
}
@@ -560,7 +570,8 @@ fun MainScreen(
onThemeModeChange: (String) -> Unit = {},
onLogout: () -> Unit = {},
onDeleteAccount: () -> Unit = {},
onAccountInfoUpdated: suspend () -> Unit = {}
onAccountInfoUpdated: suspend () -> Unit = {},
onSwitchAccount: (String) -> Unit = {}
) {
// Reactive state for account name and username
var accountName by remember { mutableStateOf(account?.name ?: "Account") }
@@ -754,7 +765,12 @@ fun MainScreen(
},
chatsViewModel = chatsListViewModel,
avatarRepository = avatarRepository,
onLogout = onLogout
onLogout = onLogout,
onAddAccount = {
// Logout current account and go to auth screen to add new one
onLogout()
},
onSwitchAccount = onSwitchAccount
)
SwipeBackContainer(

View File

@@ -9,9 +9,13 @@ import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
private val Context.dataStore: DataStore<Preferences> by
preferencesDataStore(name = "rosetta_preferences")
@@ -75,13 +79,23 @@ class PreferencesManager(private val context: Context) {
context.dataStore.edit { preferences -> preferences[IS_DARK_THEME] = value }
}
val themeMode: Flow<String> =
context.dataStore.data.map { preferences ->
preferences[THEME_MODE] ?: "dark" // Default to dark theme
}
// In-memory cache for instant theme switching (no DataStore disk I/O delay)
private val _themeMode = MutableStateFlow("dark")
private val themeModeInitScope = CoroutineScope(Dispatchers.IO)
init {
// Load persisted value on startup
themeModeInitScope.launch {
val persisted = context.dataStore.data.first()[THEME_MODE] ?: "dark"
_themeMode.value = persisted
}
}
val themeMode: Flow<String> = _themeMode
suspend fun setThemeMode(value: String) {
context.dataStore.edit { preferences -> preferences[THEME_MODE] = value }
_themeMode.value = value // Instant in-memory update
context.dataStore.edit { preferences -> preferences[THEME_MODE] = value } // Persist
}
// ═════════════════════════════════════════════════════════════

View File

@@ -21,6 +21,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed
import androidx.compose.ui.input.pointer.pointerInput
@@ -36,6 +37,8 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.airbnb.lottie.compose.*
import com.rosetta.messenger.R
import com.rosetta.messenger.data.AccountManager
import com.rosetta.messenger.data.EncryptedAccount
import com.rosetta.messenger.data.RecentSearchesManager
import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.network.ProtocolState
@@ -50,6 +53,8 @@ import com.rosetta.messenger.ui.onboarding.PrimaryBlueDark
import com.rosetta.messenger.ui.settings.BackgroundBlurPresets
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.res.painterResource
import compose.icons.TablerIcons
import compose.icons.tablericons.*
import java.text.SimpleDateFormat
@@ -176,7 +181,9 @@ fun ChatsListScreen(
onTogglePin: (String) -> Unit = {},
chatsViewModel: ChatsListViewModel = androidx.lifecycle.viewmodel.compose.viewModel(),
avatarRepository: com.rosetta.messenger.repository.AvatarRepository? = null,
onLogout: () -> Unit
onLogout: () -> Unit,
onAddAccount: () -> Unit = {},
onSwitchAccount: (String) -> Unit = {}
) {
// Theme transition state
var hasInitialized by remember { mutableStateOf(false) }
@@ -213,14 +220,14 @@ fun ChatsListScreen(
focusManager.clearFocus()
}
// Update status bar appearance
LaunchedEffect(isDarkTheme) {
if (!view.isInEditMode) {
// Update status bar appearance — SideEffect overrides global Theme.kt SideEffect
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as android.app.Activity).window
val insetsController =
androidx.core.view.WindowCompat.getInsetsController(window, view)
// Status bar
// Status bar — always white icons (header is blue)
insetsController.isAppearanceLightStatusBars = false
window.statusBarColor = android.graphics.Color.TRANSPARENT
@@ -270,6 +277,17 @@ fun ChatsListScreen(
// 📬 Requests screen state
var showRequestsScreen by remember { mutableStateOf(false) }
// 📂 Accounts section expanded state (arrow toggle)
var accountsSectionExpanded by remember { mutableStateOf(false) }
// 👥 Load all accounts for sidebar (current account always first)
var allAccounts by remember { mutableStateOf<List<EncryptedAccount>>(emptyList()) }
LaunchedEffect(accountPublicKey) {
val accountManager = AccountManager(context)
val accounts = accountManager.getAllAccounts()
allAccounts = accounts.sortedByDescending { it.publicKey == accountPublicKey }
}
// 🔥 Используем rememberSaveable чтобы сохранить состояние при навигации
// Header сразу visible = true, без анимации при возврате из чата
var visible by rememberSaveable { mutableStateOf(true) }
@@ -538,18 +556,18 @@ fun ChatsListScreen(
// Theme toggle icon
IconButton(
onClick = { onToggleTheme() },
modifier = Modifier.size(40.dp)
modifier = Modifier.size(48.dp)
) {
Icon(
imageVector =
if (isDarkTheme)
TablerIcons.Sun
else TablerIcons.Moon,
painter = painterResource(
id = if (isDarkTheme) R.drawable.day_theme_filled
else R.drawable.night_mode
),
contentDescription =
if (isDarkTheme) "Light Mode"
else "Dark Mode",
tint = Color.White,
modifier = Modifier.size(22.dp)
modifier = Modifier.size(28.dp)
)
}
}
@@ -559,35 +577,191 @@ fun ChatsListScreen(
Modifier.height(8.dp)
)
// Display name
if (accountName.isNotEmpty()) {
Text(
text = accountName,
fontSize = 16.sp,
fontWeight =
FontWeight.SemiBold,
color = Color.White
)
}
// Display name + arrow row
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
// Display name
if (accountName.isNotEmpty()) {
Text(
text = accountName,
fontSize = 15.sp,
fontWeight = FontWeight.Bold,
color = Color.White
)
}
// Username
if (accountUsername.isNotEmpty()) {
Spacer(
modifier =
Modifier.height(4.dp)
)
Text(
text =
"@$accountUsername",
fontSize = 13.sp,
color = Color.White
// Username
if (accountUsername.isNotEmpty()) {
Spacer(modifier = Modifier.height(2.dp))
Text(
text = "@$accountUsername",
fontSize = 13.sp,
color = Color.White.copy(alpha = 0.7f)
)
}
}
// Chevron arrow (toggles accounts section)
val arrowRotation by animateFloatAsState(
targetValue = if (accountsSectionExpanded) 180f else 0f,
animationSpec = tween(300),
label = "arrowRotation"
)
IconButton(
onClick = { accountsSectionExpanded = !accountsSectionExpanded },
modifier = Modifier.size(40.dp)
) {
Icon(
imageVector = TablerIcons.ChevronDown,
contentDescription = if (accountsSectionExpanded) "Collapse" else "Expand",
tint = Color.White,
modifier = Modifier
.size(20.dp)
.graphicsLayer { rotationZ = arrowRotation }
)
}
}
}
}
// ═══════════════════════════════════════════════════════════
// 📱 MENU ITEMS
// <EFBFBD> ACCOUNTS SECTION (like Telegram)
// ═══════════════════════════════════════════════════════════
AnimatedVisibility(
visible = accountsSectionExpanded,
enter = expandVertically(animationSpec = tween(250)) + fadeIn(animationSpec = tween(250)),
exit = shrinkVertically(animationSpec = tween(250)) + fadeOut(animationSpec = tween(200))
) {
Column(modifier = Modifier.fillMaxWidth()) {
// All accounts list
allAccounts.forEach { account ->
val isCurrentAccount = account.publicKey == accountPublicKey
val displayName = account.name.ifEmpty {
account.username ?: account.publicKey.take(8)
}
Row(
modifier =
Modifier.fillMaxWidth()
.height(48.dp)
.clickable {
if (!isCurrentAccount) {
scope.launch {
accountsSectionExpanded = false
drawerState.close()
kotlinx.coroutines.delay(150)
onSwitchAccount(account.publicKey)
}
}
}
.padding(start = 14.dp, end = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Account avatar
Box(
modifier = Modifier.size(36.dp),
contentAlignment = Alignment.Center
) {
AvatarImage(
publicKey = account.publicKey,
avatarRepository = avatarRepository,
size = 36.dp,
isDarkTheme = isDarkTheme,
displayName = displayName
)
// Green checkmark for current account
if (isCurrentAccount) {
Box(
modifier = Modifier
.align(Alignment.BottomEnd)
.size(14.dp)
.background(
color = drawerBackgroundColor,
shape = CircleShape
)
.padding(1.5.dp)
.background(
color = Color(0xFF50A7EA),
shape = CircleShape
),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = TablerIcons.Check,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(8.dp)
)
}
}
}
Spacer(modifier = Modifier.width(22.dp))
// Account name
Text(
text = displayName,
fontSize = 15.sp,
fontWeight = FontWeight.Bold,
color = if (isDarkTheme) Color(0xFFF4FFFFFF.toInt()) else Color(0xFF444444),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
}
}
// Add Account row
Row(
modifier =
Modifier.fillMaxWidth()
.height(48.dp)
.clickable {
scope.launch {
drawerState.close()
kotlinx.coroutines.delay(150)
onAddAccount()
}
}
.padding(start = 14.dp, end = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Plus icon in circle area
Box(
modifier = Modifier.size(36.dp),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = TablerIcons.Plus,
contentDescription = "Add Account",
tint = if (isDarkTheme) Color(0xFF828282) else Color(0xFF889198),
modifier = Modifier.size(22.dp)
)
}
Spacer(modifier = Modifier.width(22.dp))
Text(
text = "Add Account",
fontSize = 15.sp,
fontWeight = FontWeight.Bold,
color = if (isDarkTheme) Color(0xFFF4FFFFFF.toInt()) else Color(0xFF444444),
maxLines = 1
)
}
// Divider after accounts section
Divider(
color = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFE8E8E8),
thickness = 0.5.dp
)
}
} // Close AnimatedVisibility
// ═══════════════════════════════════════════════════════════
// <20>📱 MENU ITEMS
// ═══════════════════════════════════════════════════════════
Column(
modifier =
@@ -599,17 +773,21 @@ fun ChatsListScreen(
.padding(vertical = 8.dp)
) {
val menuIconColor =
if (isDarkTheme) Color(0xFF7A7F85)
else textColor.copy(alpha = 0.6f)
if (isDarkTheme) Color(0xFF828282)
else Color(0xFF889198)
val menuTextColor =
if (isDarkTheme) Color(0xFFF4FFFFFF)
else Color(0xFF444444)
val accentColor = if (isDarkTheme) PrimaryBlueDark else PrimaryBlue
// 👤 Profile
DrawerMenuItemEnhanced(
icon = TablerIcons.User,
painter = painterResource(id = R.drawable.left_status_profile),
text = "My Profile",
iconColor = menuIconColor,
textColor = textColor,
textColor = menuTextColor,
onClick = {
scope.launch {
drawerState.close()
@@ -620,12 +798,12 @@ fun ChatsListScreen(
}
)
// <EFBFBD> Requests
// 📦 Requests
DrawerMenuItemEnhanced(
icon = TablerIcons.MessageCircle2,
painter = painterResource(id = R.drawable.msg_archive),
text = "Requests",
iconColor = menuIconColor,
textColor = textColor,
textColor = menuTextColor,
badge = if (topLevelRequestsCount > 0) topLevelRequestsCount.toString() else null,
badgeColor = accentColor,
onClick = {
@@ -638,12 +816,12 @@ fun ChatsListScreen(
}
)
// <EFBFBD>📖 Saved Messages
// 📖 Saved Messages
DrawerMenuItemEnhanced(
icon = TablerIcons.Bookmark,
painter = painterResource(id = R.drawable.msg_saved),
text = "Saved Messages",
iconColor = menuIconColor,
textColor = textColor,
textColor = menuTextColor,
onClick = {
scope.launch {
drawerState.close()
@@ -658,10 +836,10 @@ fun ChatsListScreen(
// ⚙️ Settings
DrawerMenuItemEnhanced(
icon = TablerIcons.Settings,
painter = painterResource(id = R.drawable.msg_settings_old),
text = "Settings",
iconColor = menuIconColor,
textColor = textColor,
textColor = menuTextColor,
onClick = {
scope.launch {
drawerState.close()
@@ -2479,7 +2657,7 @@ fun DialogItemContent(
contentAlignment = Alignment.Center
) {
Icon(
TablerIcons.Bookmark,
painter = painterResource(R.drawable.bookmark_outlined),
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(24.dp)
@@ -3182,7 +3360,7 @@ fun RequestsScreen(
}
}
/** 🎨 Enhanced Drawer Menu Item - красивый пункт меню с hover эффектом */
/** 🎨 Enhanced Drawer Menu Item - пункт меню в стиле Telegram */
@Composable
fun DrawerMenuItemEnhanced(
icon: androidx.compose.ui.graphics.vector.ImageVector,
@@ -3196,8 +3374,9 @@ fun DrawerMenuItemEnhanced(
Row(
modifier =
Modifier.fillMaxWidth()
.height(48.dp)
.clickable(onClick = onClick)
.padding(horizontal = 20.dp, vertical = 14.dp),
.padding(start = 19.dp, end = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
@@ -3207,12 +3386,72 @@ fun DrawerMenuItemEnhanced(
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(20.dp))
Spacer(modifier = Modifier.width(29.dp))
Text(
text = text,
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
fontSize = 15.sp,
fontWeight = FontWeight.Bold,
color = textColor,
modifier = Modifier.weight(1f)
)
badge?.let {
Box(
modifier =
Modifier
.defaultMinSize(minWidth = 22.dp, minHeight = 22.dp)
.background(
color = badgeColor,
shape = CircleShape
),
contentAlignment = Alignment.Center
) {
Text(
text = it,
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
color = Color.White,
lineHeight = 12.sp,
modifier = Modifier.padding(horizontal = 5.dp, vertical = 3.dp)
)
}
}
}
}
/** 🎨 Enhanced Drawer Menu Item (Painter variant) - для XML drawable иконок */
@Composable
fun DrawerMenuItemEnhanced(
painter: Painter,
text: String,
iconColor: Color,
textColor: Color,
badge: String? = null,
badgeColor: Color = Color(0xFFE53935),
onClick: () -> Unit
) {
Row(
modifier =
Modifier.fillMaxWidth()
.height(48.dp)
.clickable(onClick = onClick)
.padding(start = 19.dp, end = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
painter = painter,
contentDescription = null,
tint = iconColor,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(29.dp))
Text(
text = text,
fontSize = 15.sp,
fontWeight = FontWeight.Bold,
color = textColor,
modifier = Modifier.weight(1f)
)

View File

@@ -13,7 +13,8 @@ import kotlin.math.min
*
* Used by ProfileMetaballOverlayCpu for devices without RenderEffect (API < 31).
*/
internal fun stackBlurBitmap(source: Bitmap, radius: Int): Bitmap {
@JvmOverloads
fun stackBlurBitmap(source: Bitmap, radius: Int): Bitmap {
if (radius < 1) return source
val bitmap = source.copy(source.config, true)
stackBlurBitmapInPlace(bitmap, radius)
@@ -23,7 +24,7 @@ internal fun stackBlurBitmap(source: Bitmap, radius: Int): Bitmap {
/**
* In-place stack blur on a mutable bitmap.
*/
internal fun stackBlurBitmapInPlace(bitmap: Bitmap, radius: Int) {
fun stackBlurBitmapInPlace(bitmap: Bitmap, radius: Int) {
if (radius < 1) return
val w = bitmap.width

View File

@@ -0,0 +1,136 @@
package com.rosetta.messenger.ui.components.metaball
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.drawable.BitmapDrawable
import android.view.Gravity
import android.view.View
import android.widget.FrameLayout
import android.widget.ImageView
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
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.graphics.Color as ComposeColor
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
/**
* Compose wrapper around Java [ProfileGooeyView] — the 1:1 Telegram port.
*
* Uses AndroidView to embed the View-based gooey effect inside Compose layout.
* The avatar content is rendered via a nested ComposeView inside ProfileGooeyView.
*
* @param collapseProgress 0 = fully expanded, 1 = fully collapsed
* @param expansionProgress 0 = normal, 1 = pulled down (overscroll)
* @param statusBarHeight status bar height in dp
* @param headerHeight current header height in dp
* @param hasAvatar whether user has an avatar image
* @param avatarColor fallback background color for avatar
* @param modifier modifier for the container
* @param avatarContent composable content to draw inside the gooey avatar
*/
@Composable
fun ProfileGooeyEffect(
collapseProgress: Float,
expansionProgress: Float,
statusBarHeight: Dp,
headerHeight: Dp,
hasAvatar: Boolean,
avatarColor: ComposeColor,
modifier: Modifier = Modifier,
avatarContent: @Composable BoxScope.() -> Unit = {},
) {
val density = LocalDensity.current
// Convert Dp to px for the View layer
val statusBarPx = with(density) { statusBarHeight.toPx() }
val headerPx = with(density) { headerHeight.toPx() }
val avatarSizeDp = 100f // matches AVATAR_SIZE_DP in ProfileGooeyView
val avatarSizePx = with(density) { avatarSizeDp.dp.toPx() }.toInt()
// Compute avatar position/scale based on collapse progress
val expandedHeaderDp = 300f // should match EXPANDED_HEADER_HEIGHT from ProfileScreen
val collapsedHeaderDp = 56f // should match COLLAPSED_HEADER_HEIGHT
val contentAreaDp = expandedHeaderDp - 70f
val avatarExpandedYDp = statusBarHeight + ((contentAreaDp - avatarSizeDp).dp / 2)
val avatarCollapsedYDp = statusBarHeight + ((collapsedHeaderDp - 30f).dp / 2) // collapsed avatar ~30dp
val avatarYDp = androidx.compose.ui.unit.lerp(avatarExpandedYDp, avatarCollapsedYDp, collapseProgress)
val avatarYPx = with(density) { avatarYDp.toPx() }.toInt()
// Avatar scale: 1.0 at expanded, ~0.3 at collapsed
val avatarScale = androidx.compose.ui.unit.lerp(1f.dp, 0.3f.dp, collapseProgress)
val avatarScaleFloat = avatarScale.value
AndroidView(
modifier = modifier,
factory = { context ->
ProfileGooeyView(context).apply {
// Add a child ComposeView that renders the avatar content
val composeChild = ComposeView(context).apply {
layoutParams = FrameLayout.LayoutParams(
avatarSizePx,
avatarSizePx,
Gravity.CENTER_HORIZONTAL or Gravity.TOP
).apply {
topMargin = avatarYPx
}
}
addView(composeChild)
setGooeyEnabled(true)
setIntensity(15f)
}
},
update = { gooeyView ->
// Update gooey parameters each recomposition
val blurIntensity = collapseProgress.coerceIn(0f, 1f)
gooeyView.setBlurIntensity(blurIntensity)
gooeyView.setPullProgress(expansionProgress)
// Update child position and scale
val child = gooeyView.getChildAt(0)
if (child != null) {
val lp = child.layoutParams as FrameLayout.LayoutParams
lp.topMargin = avatarYPx
lp.width = avatarSizePx
lp.height = avatarSizePx
child.layoutParams = lp
child.scaleX = avatarScaleFloat
child.scaleY = avatarScaleFloat
child.pivotX = avatarSizePx / 2f
child.pivotY = avatarSizePx / 2f
// Update ComposeView content
if (child is ComposeView) {
child.setContent {
Box(
modifier = Modifier
.fillMaxSize()
.background(avatarColor),
contentAlignment = Alignment.Center,
content = avatarContent
)
}
}
}
gooeyView.invalidate()
}
)
}

View File

@@ -0,0 +1,544 @@
package com.rosetta.messenger.ui.components.metaball;
import android.content.Context;
import android.content.res.Resources;
import android.graphics.Bitmap;
import android.graphics.BlendMode;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.ColorMatrixColorFilter;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.PorterDuff;
import android.graphics.PorterDuffXfermode;
import android.graphics.RectF;
import android.graphics.RenderEffect;
import android.graphics.RenderNode;
import android.graphics.Shader;
import android.os.Build;
import android.view.Gravity;
import android.view.View;
import android.widget.FrameLayout;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.core.math.MathUtils;
/**
* Port of Telegram's ProfileGooeyView.java — gooey/metaball effect for profile avatars.
* Adapted to work with rosetta-android's Kotlin utility classes
* (NotchInfoUtils, DevicePerformanceClass, CpuBlurUtils) instead of Telegram internals.
*/
public class ProfileGooeyView extends FrameLayout {
private static final float AVATAR_SIZE_DP = 100;
private static final float BLACK_KING_BAR = 32;
private final Paint blackPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Path path = new Path();
private final Impl impl;
private float intensity;
private float pullProgress;
private float blurIntensity;
private boolean enabled;
@Nullable
public NotchInfoUtils.NotchInfo notchInfo;
// ───── Utility helpers (replacing Telegram's AndroidUtilities) ─────
private static float sDensity = -1f;
private static float getDensity() {
if (sDensity < 0) {
sDensity = Resources.getSystem().getDisplayMetrics().density;
}
return sDensity;
}
/** dp → px, matching Telegram's AndroidUtilities.dp */
private static int dp(float value) {
if (value == 0) return 0;
return (int) Math.ceil(getDensity() * value);
}
/** Linear interpolation */
private static float lerp(float a, float b, float f) {
return a + f * (b - a);
}
/** Clamped range lerp: interpolates a→b as f goes from c1→c2 */
private static float lerp(float a, float b, float c1, float c2, float f) {
return lerp(a, b, MathUtils.clamp((f - c1) / (c2 - c1), 0f, 1f));
}
/** Inverse lerp: returns where x falls between a and b (0..1 unclamped) */
private static float ilerp(float x, float a, float b) {
return (x - a) / (b - a);
}
/** Get status bar height in pixels */
private static int getStatusBarHeight(Context context) {
int result = 0;
int resourceId = context.getResources().getIdentifier("status_bar_height", "dimen", "android");
if (resourceId > 0) {
result = context.getResources().getDimensionPixelSize(resourceId);
}
return result;
}
/** In-place stack blur — delegates to CpuBlurUtils.kt */
private static void stackBlurBitmap(Bitmap bitmap, int radius) {
CpuBlurUtilsKt.stackBlurBitmapInPlace(bitmap, radius);
}
// ───── Constructor ─────
public ProfileGooeyView(Context context) {
super(context);
blackPaint.setColor(Color.BLACK);
PerformanceClass perf = DevicePerformanceClass.INSTANCE.get(context);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && perf.ordinal() >= PerformanceClass.AVERAGE.ordinal()) {
impl = new GPUImpl(perf == PerformanceClass.HIGH ? 1f : 1.5f);
} else {
impl = new CPUImpl();
}
setIntensity(15f);
setBlurIntensity(0f);
setWillNotDraw(false);
}
// ───── Public API ─────
public float getEndOffset(boolean occupyStatusBar, float avatarScale) {
if (notchInfo != null) {
return -(dp(16) + (notchInfo.isLikelyCircle()
? notchInfo.getBounds().width() + notchInfo.getBounds().width() * getAvatarEndScale()
: notchInfo.getBounds().height() - notchInfo.getBounds().top));
}
return -((occupyStatusBar ? getStatusBarHeight(getContext()) : 0) + dp(16) + dp(AVATAR_SIZE_DP));
}
public float getAvatarEndScale() {
if (notchInfo != null) {
float f;
if (notchInfo.isLikelyCircle()) {
f = (notchInfo.getBounds().width() - dp(2)) / dp(AVATAR_SIZE_DP);
} else {
f = Math.min(notchInfo.getBounds().width(), notchInfo.getBounds().height()) / dp(AVATAR_SIZE_DP);
}
return Math.min(0.8f, f);
}
return 0.8f;
}
public boolean hasNotchInfo() {
return notchInfo != null;
}
public void setIntensity(float intensity) {
this.intensity = intensity;
impl.setIntensity(intensity);
invalidate();
}
public void setPullProgress(float pullProgress) {
this.pullProgress = pullProgress;
invalidate();
}
public void setBlurIntensity(float intensity) {
this.blurIntensity = intensity;
impl.setBlurIntensity(intensity);
invalidate();
}
public void setGooeyEnabled(boolean enabled) {
if (this.enabled == enabled) {
return;
}
this.enabled = enabled;
invalidate();
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
notchInfo = NotchInfoUtils.INSTANCE.getInfo(getContext());
if (notchInfo != null && (notchInfo.getGravity() != Gravity.CENTER) || getWidth() > getHeight()) {
notchInfo = null;
}
impl.onSizeChanged(w, h);
}
@Override
public void draw(@NonNull Canvas canvas) {
if (!enabled) {
super.draw(canvas);
return;
}
impl.draw(c -> {
c.save();
c.translate(0, dp(BLACK_KING_BAR));
super.draw(c);
c.restore();
}, canvas);
}
// ───── CPU implementation (all Android versions) ─────
private final class CPUImpl implements Impl {
private Bitmap bitmap;
private Canvas bitmapCanvas;
private final Paint bitmapPaint = new Paint();
private final Paint bitmapPaint2 = new Paint();
private int optimizedH;
private int optimizedW;
private int bitmapOrigW, bitmapOrigH;
private final float scaleConst = 6f;
{
bitmapPaint.setFlags(Paint.FILTER_BITMAP_FLAG | Paint.ANTI_ALIAS_FLAG | Paint.DITHER_FLAG);
bitmapPaint.setFilterBitmap(true);
bitmapPaint2.setFlags(Paint.FILTER_BITMAP_FLAG | Paint.ANTI_ALIAS_FLAG | Paint.DITHER_FLAG);
bitmapPaint2.setFilterBitmap(true);
bitmapPaint2.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_ATOP));
bitmapPaint.setColorFilter(new ColorMatrixColorFilter(new float[]{
0f, 0f, 0f, 0f, 0f,
0f, 0f, 0f, 0f, 0f,
0f, 0f, 0f, 0f, 0f,
0f, 0f, 0f, 60, -7500
}));
}
@Override
public void onSizeChanged(int w, int h) {
if (bitmap != null) {
bitmap.recycle();
}
optimizedW = Math.min(dp(120), w);
optimizedH = Math.min(dp(220), h);
bitmapOrigW = optimizedW;
bitmapOrigH = optimizedH + dp(BLACK_KING_BAR);
bitmap = Bitmap.createBitmap((int) (bitmapOrigW / scaleConst), (int) (bitmapOrigH / scaleConst), Bitmap.Config.ARGB_8888);
bitmapCanvas = new Canvas(bitmap);
}
@Override
public void draw(Drawer drawer, Canvas canvas) {
if (bitmap == null) return;
final float v = (MathUtils.clamp(blurIntensity, 0.2f, 0.3f) - 0.2f) / (0.3f - 0.2f);
final int alpha = (int) ((1f - v) * 0xFF);
final float optimizedOffsetX = (getWidth() - optimizedW) / 2f;
// Offset everything for black bar
canvas.save();
canvas.translate(0, -dp(BLACK_KING_BAR));
if (alpha != 255) {
bitmap.eraseColor(0);
bitmapCanvas.save();
bitmapCanvas.scale((float) bitmap.getWidth() / bitmapOrigW, (float) bitmap.getHeight() / bitmapOrigH);
bitmapCanvas.translate(-optimizedOffsetX, 0);
drawer.draw(bitmapCanvas);
bitmapCanvas.restore();
bitmapCanvas.save();
bitmapCanvas.scale((float) bitmap.getWidth() / bitmapOrigW, (float) bitmap.getHeight() / bitmapOrigH);
if (notchInfo != null) {
bitmapCanvas.save();
bitmapCanvas.translate(-optimizedOffsetX, dp(BLACK_KING_BAR));
if (notchInfo.isLikelyCircle()) {
float rad = Math.min(notchInfo.getBounds().width(), notchInfo.getBounds().height()) / 2f;
bitmapCanvas.drawCircle(notchInfo.getBounds().centerX(), notchInfo.getBounds().bottom - notchInfo.getBounds().width() / 2f, rad, blackPaint);
} else if (notchInfo.isAccurate()) {
bitmapCanvas.drawPath(notchInfo.getPath(), blackPaint);
} else {
float rad = Math.max(notchInfo.getBounds().width(), notchInfo.getBounds().height()) / 2f;
bitmapCanvas.drawRoundRect(notchInfo.getBounds(), rad, rad, blackPaint);
}
bitmapCanvas.restore();
} else {
bitmapCanvas.drawRect(0, 0, optimizedW, dp(BLACK_KING_BAR), blackPaint);
}
bitmapCanvas.restore();
// Blur buffer
stackBlurBitmap(bitmap, (int) (intensity * 2 / scaleConst));
// Filter alpha + fade, then draw
canvas.save();
canvas.translate(optimizedOffsetX, 0);
canvas.saveLayer(0, 0, bitmapOrigW, bitmapOrigH, null);
canvas.scale((float) bitmapOrigW / bitmap.getWidth(), (float) bitmapOrigH / bitmap.getHeight());
canvas.drawBitmap(bitmap, 0, 0, bitmapPaint);
canvas.drawBitmap(bitmap, 0, 0, bitmapPaint2);
canvas.restore();
canvas.restore();
}
// Fade, draw blurred
if (alpha != 0) {
if (alpha != 255) {
canvas.saveLayerAlpha(optimizedOffsetX, 0, optimizedOffsetX + optimizedW, optimizedH, alpha);
}
drawer.draw(canvas);
if (alpha != 255) {
canvas.restore();
}
}
canvas.restore();
}
}
// ───── GPU implementation (Android 12+ / API 31+) ─────
@RequiresApi(api = Build.VERSION_CODES.S)
private final class GPUImpl implements Impl {
private final Paint filter = new Paint(Paint.ANTI_ALIAS_FLAG);
private final RenderNode node = new RenderNode("render");
private final RenderNode effectNotchNode = new RenderNode("effectNotch");
private final RenderNode effectNode = new RenderNode("effect");
private final RenderNode blurNode = new RenderNode("blur");
private final float factorMult;
private final RectF whole = new RectF();
private final RectF temp = new RectF();
private final Paint blackNodePaint = new Paint();
private GPUImpl(float factorMult) {
this.factorMult = factorMult;
blackNodePaint.setColor(Color.BLACK);
blackNodePaint.setBlendMode(BlendMode.SRC_IN);
}
@Override
public void setIntensity(float intensity) {
effectNode.setRenderEffect(RenderEffect.createBlurEffect(intensity, intensity, Shader.TileMode.CLAMP));
effectNotchNode.setRenderEffect(RenderEffect.createBlurEffect(intensity, intensity, Shader.TileMode.CLAMP));
filter.setColorFilter(new ColorMatrixColorFilter(new float[]{
1f, 0f, 0f, 0f, 0f,
0f, 1f, 0f, 0f, 0f,
0f, 0f, 1f, 0f, 0f,
0f, 0f, 0f, 51, 51 * -125
}));
}
@Override
public void setBlurIntensity(float blurIntensity) {
if (blurIntensity == 0) {
blurNode.setRenderEffect(null);
return;
}
blurNode.setRenderEffect(RenderEffect.createBlurEffect(blurIntensity * intensity / factorMult, blurIntensity * intensity / factorMult, Shader.TileMode.DECAL));
}
private final RectF wholeOptimized = new RectF();
@Override
public void draw(Drawer drawer, @NonNull Canvas canvas) {
if (!canvas.isHardwareAccelerated()) {
return;
}
Canvas c;
whole.set(0, 0, getWidth(), getHeight());
if (getChildCount() > 0) {
final View child = getChildAt(0);
final float w = child.getWidth() * child.getScaleX();
final float h = child.getHeight() * child.getScaleY();
final float l = child.getX();
final float t = child.getY();
wholeOptimized.set(l, t, l + w, t + h);
if (notchInfo != null) {
wholeOptimized.union(notchInfo.getBounds());
}
wholeOptimized.inset(-dp(20), -dp(20));
wholeOptimized.intersect(whole);
wholeOptimized.top = 0;
} else {
wholeOptimized.set(whole);
}
wholeOptimized.bottom += dp(BLACK_KING_BAR);
final int width = (int) Math.ceil(wholeOptimized.width());
final int height = (int) Math.ceil(wholeOptimized.height());
final float left = wholeOptimized.left;
final float top = wholeOptimized.top;
node.setPosition(0, 0, width, height);
blurNode.setPosition(0, 0, width, height);
effectNode.setPosition(0, 0, width, height);
effectNotchNode.setPosition(0, 0, width, height);
wholeOptimized.set(0, 0, width, height);
// Record everything into buffer
c = node.beginRecording();
c.translate(-left, -top);
final int imageAlphaNoClamp = (int) ((1f - ilerp(pullProgress, 0.5f, 1.0f)) * 255);
final int imageAlpha = MathUtils.clamp(imageAlphaNoClamp, 0, 255);
drawer.draw(c);
node.endRecording();
// Blur only buffer
float blurScaleFactor = factorMult / 4f + 1f + blurIntensity * 0.5f * factorMult + (factorMult - 1f) * 2f;
c = blurNode.beginRecording();
c.scale(1f / blurScaleFactor, 1f / blurScaleFactor, 0, 0);
c.drawRenderNode(node);
blurNode.endRecording();
// Blur + filter buffer
float gooScaleFactor = 2f + factorMult;
c = effectNode.beginRecording();
c.scale(1f / gooScaleFactor, 1f / gooScaleFactor, 0, 0);
if (imageAlpha < 255) {
c.saveLayer(wholeOptimized, null);
c.drawRenderNode(node);
c.drawRect(wholeOptimized, blackNodePaint);
c.restore();
}
final float h = lerp(0, dp(7) * gooScaleFactor, 0, 0.5f, pullProgress);
if (getChildCount() > 0) {
final View child = getChildAt(0);
final float cx = child.getX() + child.getWidth() * child.getScaleX() / 2.0f - left;
final float cy = child.getY() + child.getHeight() * child.getScaleY() / 2.0f + dp(BLACK_KING_BAR) - top;
final float r = child.getWidth() / 2.0f * child.getScaleX();
path.rewind();
path.moveTo(cx - r, cy - (float) Math.cos(Math.PI / 4) * r);
path.lineTo(cx, cy - r - h * 0.25f);
path.lineTo(cx + r, cy - (float) Math.cos(Math.PI / 4) * r);
path.close();
c.drawPath(path, blackPaint);
}
if (imageAlpha > 0) {
if (imageAlpha != 255) {
c.saveLayerAlpha(wholeOptimized, imageAlpha);
}
c.drawRenderNode(node);
if (imageAlpha != 255) {
c.restore();
}
}
effectNode.endRecording();
c = effectNotchNode.beginRecording();
c.scale(1f / gooScaleFactor, 1f / gooScaleFactor, 0, 0);
if (notchInfo != null) {
c.translate(-left, -top);
c.translate(0, dp(BLACK_KING_BAR));
if (notchInfo.isLikelyCircle()) {
float rad = Math.min(notchInfo.getBounds().width(), notchInfo.getBounds().height()) / 2f;
final float cy = notchInfo.getBounds().bottom - notchInfo.getBounds().width() / 2f;
c.drawCircle(notchInfo.getBounds().centerX(), cy, rad, blackPaint);
path.rewind();
path.moveTo(notchInfo.getBounds().centerX() - h / 2f, cy);
path.lineTo(notchInfo.getBounds().centerX(), cy + rad + h);
path.lineTo(notchInfo.getBounds().centerX() + h / 2f, cy);
path.close();
c.drawPath(path, blackPaint);
} else if (notchInfo.isAccurate()) {
c.drawPath(notchInfo.getPath(), blackPaint);
} else {
float rad = Math.max(notchInfo.getBounds().width(), notchInfo.getBounds().height()) / 2f;
temp.set(notchInfo.getBounds());
c.drawRoundRect(temp, rad, rad, blackPaint);
path.rewind();
path.moveTo(temp.centerX() - h / 2f, temp.bottom);
path.lineTo(temp.centerX(), temp.bottom + h);
path.lineTo(temp.centerX() + h / 2f, temp.bottom);
path.close();
c.drawPath(path, blackPaint);
}
} else {
c.drawRect(0, 0, width, dp(BLACK_KING_BAR), blackPaint);
path.rewind();
path.moveTo((width - h) / 2f, dp(BLACK_KING_BAR));
path.lineTo((width) / 2f, dp(BLACK_KING_BAR) + h);
path.lineTo((width + h) / 2f, dp(BLACK_KING_BAR));
path.close();
c.drawPath(path, blackPaint);
}
effectNotchNode.endRecording();
// Offset everything for black bar
canvas.save();
canvas.translate(left, top - dp(BLACK_KING_BAR));
if (notchInfo != null) {
canvas.clipRect(0, notchInfo.getBounds().top, width, height);
}
// Filter alpha + fade, then draw
canvas.saveLayer(wholeOptimized, filter);
canvas.scale(gooScaleFactor, gooScaleFactor);
canvas.drawRenderNode(effectNotchNode);
canvas.drawRenderNode(effectNode);
canvas.restore();
// Fade, draw blurred
final int blurImageAlpha = MathUtils.clamp(imageAlphaNoClamp * 3 / 4, 0, 255);
if (blurImageAlpha < 255) {
canvas.saveLayer(wholeOptimized, null);
if (blurIntensity != 0) {
canvas.saveLayer(wholeOptimized, filter);
canvas.scale(blurScaleFactor, blurScaleFactor);
canvas.drawRenderNode(blurNode);
canvas.restore();
} else {
canvas.drawRenderNode(node);
}
canvas.drawRect(wholeOptimized, blackNodePaint);
canvas.restore();
}
if (blurImageAlpha > 0) {
if (blurImageAlpha != 255) {
canvas.saveLayerAlpha(wholeOptimized, blurImageAlpha);
}
if (blurIntensity != 0) {
canvas.saveLayer(wholeOptimized, filter);
canvas.scale(blurScaleFactor, blurScaleFactor);
canvas.drawRenderNode(blurNode);
canvas.restore();
} else {
canvas.drawRenderNode(node);
}
if (blurImageAlpha != 255) {
canvas.restore();
}
}
canvas.restore();
}
}
// ───── Impl interface ─────
private interface Impl {
default void setIntensity(float intensity) {}
default void setBlurIntensity(float intensity) {}
default void onSizeChanged(int w, int h) {}
void draw(Drawer drawer, Canvas canvas);
}
private interface Drawer {
void draw(Canvas canvas);
}
}

View File

@@ -12,6 +12,7 @@ import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.foundation.*
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.WindowInsets
@@ -59,6 +60,7 @@ import com.rosetta.messenger.biometric.BiometricAvailability
import com.rosetta.messenger.biometric.BiometricPreferences
import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.components.BlurredAvatarBackground
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import com.rosetta.messenger.ui.onboarding.PrimaryBlueDark
@@ -427,19 +429,21 @@ fun ProfileScreen(
// ═══════════════════════════════════════════════════════════════
// TELEGRAM ARCHITECTURE: LazyColumn = RecyclerView
// Item 0 = spacer высотой с expanded header (как Telegram item 0)
// scrollOffset вычисляется из позиции скролла (как Telegram extraHeight)
// Контент и хедер двигаются SYNC — один скролл двигает всё
// Item 0 = spacer высотой maxScrollOffset (ровно сколько нужно проскроллить)
// scrollOffset = firstVisibleItemScrollOffset (напрямую, без coerce)
// LazyColumn имеет padding-top = collapsedHeight чтобы контент
// не залезал под collapsed хедер.
// Мёртвой зоны нет — каждый пиксель скролла двигает хедер.
// ═══════════════════════════════════════════════════════════════
val listState = rememberLazyListState()
val expandedHeaderDp = with(density) { expandedHeightPx.toDp() }
val spacerHeightDp = with(density) { maxScrollOffset.toDp() }
val collapsedHeightDp = with(density) { collapsedHeightPx.toDp() }
// Derive scrollOffset from LazyColumn scroll — как Telegram checkListViewScroll()
// item 0 top position → extraHeight
// scrollOffset напрямую из LazyColumn — как Telegram checkListViewScroll()
val scrollOffset by remember {
derivedStateOf {
if (listState.firstVisibleItemIndex == 0) {
listState.firstVisibleItemScrollOffset.toFloat().coerceAtMost(maxScrollOffset)
listState.firstVisibleItemScrollOffset.toFloat()
} else {
maxScrollOffset
}
@@ -484,17 +488,13 @@ fun ProfileScreen(
// isPulledDown имеет ВЫСШИЙ ПРИОРИТЕТ - игнорирует isDragging
// ═══════════════════════════════════════════════════════════════
val targetOverscroll = when {
isPulledDown -> maxOverscroll // 🔥 ВЫСШИЙ ПРИОРИТЕТ: snap сработал - держим раскрытым!
isDragging -> overscrollOffset // Во время drag (до порога) - следуем за пальцем
overscrollOffset > snapThreshold -> maxOverscroll // Перешли порог - snap к max
else -> 0f // Не дотянули - snap обратно
isPulledDown -> maxOverscroll
isDragging -> overscrollOffset
overscrollOffset > snapThreshold -> maxOverscroll
else -> 0f
}
// 🔥 FIX: Когда isPulledDown=true - анимация должна быть МГНОВЕННОЙ
// чтобы аватарка сразу заполнилась после порога
val currentProgress = (overscrollOffset / maxOverscroll).coerceIn(0f, 1f)
// Плавная spring анимация для snap (без bounce для гладкости)
// Spring анимация для snap (без bounce)
val animatedOverscroll by animateFloatAsState(
targetValue = targetOverscroll,
animationSpec = if (isDragging && !isPulledDown) {
@@ -590,7 +590,7 @@ fun ProfileScreen(
source: NestedScrollSource
): Offset {
// Overscroll при свайпе вниз от верха (когда LazyColumn в начале)
if (available.y > 0 && scrollOffset == 0f) {
if (available.y > 0 && !listState.canScrollBackward) {
isDragging = true
val resistance = if (isPulledDown) 1f else 0.5f
val delta = available.y * resistance
@@ -625,7 +625,8 @@ fun ProfileScreen(
}
}
// HEADER SNAP — как Telegram smoothScrollBy в ACTION_UP
// HEADER SNAP — Telegram smoothScrollBy(dy, EASE_OUT_QUINT)
// animateScrollBy = Compose эквивалент smoothScrollBy
val currentOffset = scrollOffset
if (currentOffset > 0f && currentOffset < maxScrollOffset) {
val progress = currentOffset / maxScrollOffset
@@ -635,14 +636,18 @@ fun ProfileScreen(
progress >= 0.6f -> true
else -> false
}
if (snapToCollapsed) {
// Snap to collapsed — доскроллить spacer вверх
listState.animateScrollToItem(0, maxScrollOffset.toInt())
val snapDelta = if (snapToCollapsed) {
maxScrollOffset - currentOffset // скролл вперёд = collapse
} else {
// Snap to expanded — вернуть spacer в начало
listState.animateScrollToItem(0, 0)
-currentOffset // скролл назад = expand
}
// Поглощаем velocity — LazyColumn не fling'ит
listState.animateScrollBy(
value = snapDelta,
animationSpec = tween(
durationMillis = 250,
easing = CubicBezierEasing(0.25f, 1f, 0.5f, 1f)
)
)
return available
}
@@ -725,16 +730,16 @@ fun ProfileScreen(
.nestedScroll(nestedScrollConnection)
) {
// Scrollable content — Telegram architecture:
// Item 0 = spacer (как Telegram RecyclerView item 0)
// Скролл LazyColumn двигает КОНТЕНТ + хедер вместе
// Item 0 = spacer (ровно maxScrollOffset px) → каждый пиксель скролла двигает хедер
// padding-top = collapsedHeightDp → контент не залезает под collapsed хедер
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize()
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(top = collapsedHeightDp)
) {
// Item 0: spacer высотой с раскрытый хедер
// Когда скроллим вверх — spacer уходит, scrollOffset растёт
// Item 0: spacer = ровно сколько нужно проскроллить для collapse
item {
Spacer(modifier = Modifier.fillMaxWidth().height(expandedHeaderDp))
Spacer(modifier = Modifier.fillMaxWidth().height(spacerHeightDp))
}
item {
Spacer(modifier = Modifier.height(16.dp))
@@ -961,6 +966,43 @@ fun ProfileScreen(
}
}
)
// ═══════════════════════════════════════════════════════════
// 📷 CAMERA BUTTON — at boundary between header and content
// Positioned at bottom-right of header, half overlapping content area
// Fades out when collapsed or when avatar is expanded
// ═══════════════════════════════════════════════════════════
val cameraButtonAlpha = (1f - collapseProgress * 2.5f).coerceIn(0f, 1f) *
(1f - expansionProgress * 4f).coerceIn(0f, 1f)
if (cameraButtonAlpha > 0.01f) {
val cameraButtonSize = 52.dp
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.offset(
x = (-16).dp,
y = headerHeight - cameraButtonSize / 2
)
.size(cameraButtonSize)
.graphicsLayer { alpha = cameraButtonAlpha }
.shadow(
elevation = 4.dp,
shape = CircleShape,
clip = false
)
.clip(CircleShape)
.background(Color.White)
.clickable { showPhotoPicker = true },
contentAlignment = Alignment.Center
) {
Icon(
imageVector = TablerIcons.CameraPlus,
contentDescription = "Change avatar",
tint = Color(0xFF8E8E93),
modifier = Modifier.size(24.dp)
)
}
}
}
// 🖼️ Кастомный быстрый Photo Picker
@@ -1169,46 +1211,24 @@ private fun CollapsingProfileHeader(
}
// ═══════════════════════════════════════════════════════════
// <EFBFBD> ADD/CHANGE AVATAR BUTTON — bottom-right of avatar circle
// Fades out on collapse and expansion
// ═══════════════════════════════════════════════════════════
val cameraButtonAlpha = avatarAlpha * (1f - expandFraction * 4f).coerceIn(0f, 1f)
if (cameraButtonAlpha > 0.01f) {
val cameraButtonSize = 44.dp
// Position: bottom-right of the avatar circle
val avatarCenterXPos = screenWidth / 2
val avatarCenterYPos = avatarY + avatarSize / 2
// Offset to bottom-right edge of circle (45° from center)
val offsetFromCenter = avatarSize / 2 * 0.7f // cos(45°) ≈ 0.707
val cameraX = avatarCenterXPos + offsetFromCenter - cameraButtonSize / 2
val cameraY = avatarCenterYPos + offsetFromCenter - cameraButtonSize / 2
Box(
modifier = Modifier
.offset(x = cameraX, y = cameraY)
.size(cameraButtonSize)
.graphicsLayer { alpha = cameraButtonAlpha }
.clip(CircleShape)
.background(Color(0xFF3A3A3C))
.clickable { onSetPhotoClick() },
contentAlignment = Alignment.Center
) {
Icon(
imageVector = TablerIcons.CameraPlus,
contentDescription = "Change avatar",
tint = Color.White,
modifier = Modifier.size(22.dp)
)
}
}
// ═══════════════════════════════════════════════════════════
// <20>🔙 BACK BUTTON
// 🔙 BACK BUTTON - Aligned with text vertical center
// ═══════════════════════════════════════════════════════════
Box(
modifier =
Modifier.padding(top = statusBarHeight)
.padding(start = 4.dp, top = 4.dp)
Modifier
.align(Alignment.TopStart)
.offset(x = 4.dp, y = textY)
.graphicsLayer {
val centerOffsetY =
with(density) {
androidx.compose
.ui
.unit
.lerp(24.dp, 18.dp, collapseProgress)
.toPx()
}
translationY = -centerOffsetY
}
.size(48.dp),
contentAlignment = Alignment.Center
) {
@@ -1223,13 +1243,23 @@ private fun CollapsingProfileHeader(
}
// ═══════════════════════════════════════════════════════════
// ⋮ MENU BUTTON / 💾 SAVE BUTTON
// ⋮ MENU BUTTON / 💾 SAVE BUTTON - Aligned with text vertical center
// ═══════════════════════════════════════════════════════════
Box(
modifier =
Modifier.align(Alignment.TopEnd)
.padding(top = statusBarHeight)
.padding(end = 4.dp, top = 4.dp),
.offset(x = -4.dp, y = textY)
.graphicsLayer {
val centerOffsetY =
with(density) {
androidx.compose
.ui
.unit
.lerp(24.dp, 18.dp, collapseProgress)
.toPx()
}
translationY = -centerOffsetY
},
contentAlignment = Alignment.Center
) {
AnimatedVisibility(visible = hasChanges, enter = fadeIn(), exit = fadeOut()) {
@@ -1750,30 +1780,35 @@ private fun TelegramToggleItem(
}
}
// Material 2 / old Telegram style switch
// Telegram-style switch with outlined thumb
val thumbOffset by animateFloatAsState(
targetValue = if (isEnabled) 1f else 0f,
animationSpec = tween(durationMillis = 150),
animationSpec = tween(durationMillis = 200),
label = "thumb"
)
val trackColor by animateColorAsState(
targetValue = if (isEnabled) accentColor.copy(alpha = 0.5f)
else if (isDarkTheme) Color(0xFF39393D) else Color(0xFFBDBDBD),
animationSpec = tween(durationMillis = 150),
targetValue = if (isEnabled) accentColor
else if (isDarkTheme) Color(0xFF5A5A5E) else Color(0xFF999999),
animationSpec = tween(durationMillis = 200),
label = "track"
)
val thumbColor by animateColorAsState(
val thumbBorderColor by animateColorAsState(
targetValue = if (isEnabled) accentColor
else if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFFF1F1F1),
animationSpec = tween(durationMillis = 150),
label = "thumbColor"
else if (isDarkTheme) Color(0xFF5A5A5E) else Color(0xFF999999),
animationSpec = tween(durationMillis = 200),
label = "thumbBorder"
)
val trackWidth = 38.dp
val trackHeight = 22.dp
val thumbSize = 26.dp
val borderWidth = 2.dp
val thumbTravel = trackWidth - thumbSize + borderWidth
Box(
modifier = Modifier
.width(37.dp)
.height(20.dp)
.clip(RoundedCornerShape(10.dp))
.background(trackColor)
.width(trackWidth)
.height(thumbSize)
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() },
@@ -1781,13 +1816,25 @@ private fun TelegramToggleItem(
),
contentAlignment = Alignment.CenterStart
) {
// Track
Box(
modifier = Modifier
.offset(x = (17.dp * thumbOffset))
.size(20.dp)
.shadow(2.dp, CircleShape)
.width(trackWidth)
.height(trackHeight)
.clip(RoundedCornerShape(trackHeight / 2))
.background(trackColor)
.align(Alignment.Center)
)
// Thumb with border
Box(
modifier = Modifier
.offset(x = thumbTravel * thumbOffset)
.size(thumbSize)
.clip(CircleShape)
.background(thumbColor)
.background(thumbBorderColor)
.padding(borderWidth)
.clip(CircleShape)
.background(Color.White)
)
}
}