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:
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user