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.
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
BIN
app/src/main/res/drawable-hdpi/left_status_profile.png
Normal file
|
After Width: | Height: | Size: 538 B |
BIN
app/src/main/res/drawable-hdpi/msg_archive.png
Normal file
|
After Width: | Height: | Size: 384 B |
BIN
app/src/main/res/drawable-hdpi/msg_saved.png
Normal file
|
After Width: | Height: | Size: 321 B |
BIN
app/src/main/res/drawable-hdpi/msg_settings_old.png
Normal file
|
After Width: | Height: | Size: 604 B |
BIN
app/src/main/res/drawable-mdpi/left_status_profile.png
Normal file
|
After Width: | Height: | Size: 381 B |
BIN
app/src/main/res/drawable-mdpi/msg_archive.png
Normal file
|
After Width: | Height: | Size: 274 B |
BIN
app/src/main/res/drawable-mdpi/msg_saved.png
Normal file
|
After Width: | Height: | Size: 188 B |
BIN
app/src/main/res/drawable-mdpi/msg_settings_old.png
Normal file
|
After Width: | Height: | Size: 411 B |
BIN
app/src/main/res/drawable-xhdpi/left_status_profile.png
Normal file
|
After Width: | Height: | Size: 622 B |
BIN
app/src/main/res/drawable-xhdpi/msg_archive.png
Normal file
|
After Width: | Height: | Size: 373 B |
BIN
app/src/main/res/drawable-xhdpi/msg_saved.png
Normal file
|
After Width: | Height: | Size: 302 B |
BIN
app/src/main/res/drawable-xhdpi/msg_settings_old.png
Normal file
|
After Width: | Height: | Size: 669 B |
BIN
app/src/main/res/drawable-xxhdpi/left_status_profile.png
Normal file
|
After Width: | Height: | Size: 877 B |
BIN
app/src/main/res/drawable-xxhdpi/msg_archive.png
Normal file
|
After Width: | Height: | Size: 463 B |
BIN
app/src/main/res/drawable-xxhdpi/msg_saved.png
Normal file
|
After Width: | Height: | Size: 356 B |
BIN
app/src/main/res/drawable-xxhdpi/msg_settings_old.png
Normal file
|
After Width: | Height: | Size: 798 B |
16
app/src/main/res/drawable/account_circle_outlined.xml
Normal file
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
|
||||
<path
|
||||
android:fillColor="#50A7EA"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M12 14.25C14.0711 14.25 15.75 12.5711 15.75 10.5C15.75 8.42893 14.0711 6.75 12 6.75C9.92893 6.75 8.25 8.42893 8.25 10.5C8.25 12.5711 9.92893 14.25 12 14.25ZM12 13.125C13.4497 13.125 14.625 11.9497 14.625 10.5C14.625 9.05025 13.4497 7.875 12 7.875C10.5503 7.875 9.375 9.05025 9.375 10.5C9.375 11.9497 10.5503 13.125 12 13.125Z" />
|
||||
<path
|
||||
android:fillColor="#50A7EA"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12ZM16.6955 18.3226C15.3845 19.2979 13.7596 19.875 12 19.875C10.2404 19.875 8.61554 19.2979 7.30446 18.3226C7.83038 17.7315 8.4646 17.2434 9.17493 16.8859C10.0831 16.4289 11.0885 16.1991 12.105 16.2161C13.1216 16.2331 14.1188 16.4964 15.0111 16.9835C15.6473 17.3307 16.2167 17.7843 16.6955 18.3226ZM17.612 17.5246C17.5897 17.4994 17.5673 17.4744 17.5446 17.4495C16.9856 16.8358 16.3267 16.3171 15.5933 15.9169C14.5284 15.3356 13.3384 15.0213 12.1254 15.001C10.9123 14.9808 9.71243 15.2551 8.62869 15.8004C7.88242 16.176 7.20655 16.6723 6.62728 17.267C6.54556 17.3509 6.46576 17.4368 6.38796 17.5246C4.9885 16.1031 4.125 14.1524 4.125 12C4.125 7.65076 7.65076 4.125 12 4.125C16.3492 4.125 19.875 7.65076 19.875 12C19.875 14.1524 19.0115 16.1031 17.612 17.5246Z" />
|
||||
</vector>
|
||||
12
app/src/main/res/drawable/archive_filled.xml
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
|
||||
<path
|
||||
android:fillColor="#50A7EA"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M21 18.7847V7.43065C21 7.19989 21 6.59991 20.7097 6.32299L17.5161 3.27692C17.4194 3.18461 17.1097 3 16.6452 3H7.35484C6.89032 3 6.58065 3.18461 6.48387 3.27692L3.29032 6.32299C3 6.59991 3 7.19989 3 7.43065V18.7847C3 20.0082 4.03985 21 5.32258 21H18.6774C19.9601 21 21 20.0082 21 18.7847ZM16.6452 4.38458H7.35484L5.6129 6.04607H18.3871L16.6452 4.38458ZM13.7419 11.3075V12.4151H16.1051C16.4052 12.4151 16.5517 12.7812 16.3346 12.9882L12 17.1227L7.66537 12.9882C7.44826 12.7812 7.59483 12.4151 7.89486 12.4151H10.2581V11.3075C10.2581 10.8644 10.6452 10.7536 10.8387 10.7536H13.1613C13.6258 10.7536 13.7419 11.1229 13.7419 11.3075Z" />
|
||||
</vector>
|
||||
12
app/src/main/res/drawable/bookmark_outlined.xml
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
|
||||
<path
|
||||
android:fillColor="#50A7EA"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M16.8863 3C17.8716 3.0001 18.559 3.42291 18.9792 4.00261C19.3739 4.54736 19.4999 5.18214 19.5 5.61839V19.736C19.4999 20.0305 19.4425 20.4146 19.1561 20.699C18.8563 20.9964 18.4776 21.0194 18.2187 20.9908C17.9572 20.962 17.6836 20.8684 17.4313 20.7653C17.1708 20.6587 16.8831 20.5199 16.5855 20.3721L12.475 18.4765C12.4703 18.4747 12.4626 18.4724 12.4534 18.4691C12.4287 18.4603 12.3902 18.4471 12.3421 18.4341C12.2411 18.4068 12.1176 18.3844 12 18.3844C11.8825 18.3844 11.7597 18.4069 11.6588 18.4341C11.6103 18.4472 11.5713 18.4603 11.5466 18.4691C11.5394 18.4717 11.5331 18.4729 11.5286 18.4746L7.39746 20.3804C7.10599 20.525 6.8251 20.6608 6.56962 20.7653C6.31719 20.8685 6.04293 20.9619 5.78128 20.9908C5.52241 21.0193 5.14362 20.9964 4.84389 20.699C4.55753 20.4146 4.50006 20.0305 4.5 19.736V5.61839C4.50011 4.61389 4.93879 3.92289 5.5173 3.50913C6.05967 3.12144 6.68795 3 7.11463 3H16.8863ZM7.11463 4.41415C6.89985 4.41415 6.56514 4.4836 6.30564 4.66918C6.08241 4.82886 5.87924 5.09416 5.87915 5.61839V19.5159C5.93091 19.4979 5.99153 19.4789 6.05872 19.4515C6.27002 19.365 6.51944 19.246 6.81384 19.0998L6.82282 19.0952L6.8318 19.0915L10.9773 17.1793L10.9872 17.1746L10.9971 17.171L10.9989 17.17C10.9997 17.1697 11.0006 17.1686 11.0016 17.1682C11.0035 17.1674 11.0061 17.1665 11.0087 17.1654C11.0142 17.1632 11.0212 17.1603 11.0294 17.1572C11.0457 17.1508 11.0676 17.1426 11.094 17.1332C11.1467 17.1145 11.2194 17.0906 11.3068 17.0669C11.4777 17.0207 11.7249 16.9703 12 16.9703C12.275 16.9703 12.5223 17.0207 12.6932 17.0669C12.7804 17.0905 12.8534 17.1145 12.906 17.1332C12.9322 17.1426 12.9543 17.1508 12.9706 17.1572C12.9786 17.1603 12.9859 17.1632 12.9913 17.1654C12.9938 17.1665 12.9965 17.1674 12.9984 17.1682C12.9993 17.1687 13.0004 17.1697 13.0011 17.17H13.0029L13.0038 17.171L13.0137 17.1746L13.0236 17.1793L17.1691 19.0915L17.1781 19.0952L17.1862 19.0998C17.4806 19.246 17.7309 19.365 17.9422 19.4515C18.0091 19.4788 18.0693 19.4979 18.1209 19.5159V5.61839C18.1208 5.41763 18.0542 5.09723 17.8721 4.84595C17.715 4.62922 17.4403 4.41424 16.8863 4.41415H7.11463Z" />
|
||||
</vector>
|
||||
36
app/src/main/res/drawable/day_theme_filled.xml
Normal file
@@ -0,0 +1,36 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
|
||||
<path
|
||||
android:fillColor="#50A7EA"
|
||||
android:pathData="M12 3C11.5482 3 11.1818 3.36634 11.1818 3.81818V4.63636C11.1818 5.0882 11.5482 5.45455 12 5.45455C12.4518 5.45455 12.8182 5.0882 12.8182 4.63636V3.81818C12.8182 3.36634 12.4518 3 12 3Z" />
|
||||
<path
|
||||
android:fillColor="#50A7EA"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M12 16.9091C14.7112 16.9091 16.9091 14.7112 16.9091 12C16.9091 9.28877 14.7112 7.09091 12 7.09091C9.28877 7.09091 7.09091 9.28877 7.09091 12C7.09091 14.7112 9.28877 16.9091 12 16.9091Z" />
|
||||
<path
|
||||
android:fillColor="#50A7EA"
|
||||
android:pathData="M11.1818 19.3636C11.1818 18.9118 11.5482 18.5455 12 18.5455C12.4518 18.5455 12.8182 18.9118 12.8182 19.3636V20.1818C12.8182 20.6337 12.4518 21 12 21C11.5482 21 11.1818 20.6337 11.1818 20.1818V19.3636Z" />
|
||||
<path
|
||||
android:fillColor="#50A7EA"
|
||||
android:pathData="M19.3648 12.8075C18.913 12.8082 18.5461 12.4423 18.5455 11.9905C18.5449 11.5387 18.9106 11.1718 19.3624 11.1711L20.1806 11.1699C20.6325 11.1693 20.9994 11.5351 21 11.987C21.0006 12.4389 20.6349 12.8057 20.183 12.8063L19.3648 12.8075Z" />
|
||||
<path
|
||||
android:fillColor="#50A7EA"
|
||||
android:pathData="M3 12.013C3.0006 12.4648 3.36754 12.8307 3.81938 12.83L4.63756 12.8288C5.0894 12.8282 5.45515 12.4613 5.45455 12.0095C5.45395 11.5576 5.087 11.1918 4.63517 11.1924L3.81698 11.1936C3.36515 11.1943 2.9994 11.5611 3 12.013Z" />
|
||||
<path
|
||||
android:fillColor="#50A7EA"
|
||||
android:pathData="M17.6661 7.22634C17.3547 7.55393 16.837 7.56702 16.5094 7.2557C16.182 6.94439 16.1688 6.42654 16.48 6.09904L17.0437 5.50598C17.3551 5.17849 17.8729 5.16531 18.2005 5.47662C18.5281 5.78793 18.5411 6.30579 18.2298 6.63338L17.6661 7.22634Z" />
|
||||
<path
|
||||
android:fillColor="#50A7EA"
|
||||
android:pathData="M5.79952 18.5233C6.12711 18.8346 6.64486 18.8215 6.95628 18.4939L7.51998 17.901C7.83119 17.5734 7.8182 17.0555 7.49061 16.7442C7.16302 16.4329 6.64526 16.4461 6.33385 16.7736L5.77015 17.3666C5.45894 17.6941 5.47212 18.212 5.79952 18.5233Z" />
|
||||
<path
|
||||
android:fillColor="#50A7EA"
|
||||
android:pathData="M16.8182 17.6283C16.4882 17.3196 16.471 16.8018 16.7797 16.4718C17.0885 16.1418 17.6062 16.1247 17.9362 16.4334L18.5337 16.9924C18.8637 17.3011 18.8808 17.8189 18.572 18.1488C18.2634 18.4787 17.7456 18.496 17.4157 18.1873L16.8182 17.6283Z" />
|
||||
<path
|
||||
android:fillColor="#50A7EA"
|
||||
android:pathData="M5.42798 5.85115C5.11936 6.18114 5.13654 6.6989 5.46653 7.00761L6.06399 7.56662C6.39398 7.87533 6.91173 7.85805 7.22035 7.52816C7.52916 7.19818 7.51199 6.68042 7.182 6.3717L6.58454 5.8127C6.25455 5.50398 5.73679 5.52116 5.42798 5.85115Z" />
|
||||
</vector>
|
||||
12
app/src/main/res/drawable/folder_outlined.xml
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
|
||||
<path
|
||||
android:fillColor="#50A7EA"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M5.22662 4C4.86221 4 4.32899 4.10415 3.86679 4.43905C3.36968 4.79864 3 5.39616 3 6.25047V16.7495C3 17.1226 3.10559 17.6644 3.44069 18.1322C3.80049 18.635 4.39068 19 5.22662 19H18.7734C19.1377 19 19.671 18.8954 20.1332 18.5609C20.6303 18.2009 21 17.6034 21 16.7495V8.52025C21 8.12384 20.8873 7.54652 20.531 7.04911C20.15 6.51669 19.5246 6.12836 18.6322 6.12836H12.8467C12.4553 6.12836 12.1847 6.04621 11.9632 5.91692C11.7312 5.78179 11.5107 5.57124 11.2531 5.24757L11.2295 5.21794L11.2028 5.191L11.1983 5.18651L11.1849 5.17215L11.1411 5.12501L11.1375 5.12097C11.1026 5.08326 11.0576 5.03523 11.0053 4.98135C10.8976 4.87047 10.7544 4.7313 10.5865 4.59438C10.2787 4.34298 9.77129 4 9.1777 4H5.22662ZM4.34875 6.25047C4.34875 5.85586 4.49649 5.6727 4.64609 5.56451C4.83071 5.43073 5.0735 5.3791 5.22662 5.3791H9.1777C9.28977 5.3791 9.48789 5.46216 9.74462 5.6718C9.80609 5.72208 9.8613 5.77102 9.91146 5.81815C10.009 5.90929 10.0874 5.99369 10.1547 6.06641L10.2287 6.14542C10.5301 6.52028 10.868 6.8664 11.2959 7.11555C11.7447 7.37683 12.2503 7.50747 12.8467 7.50747H18.6322C18.8928 7.50747 19.0768 7.56403 19.2104 7.64529C19.314 7.70859 19.3874 7.78625 19.4428 7.86347C19.5945 8.07536 19.6513 8.34921 19.6513 8.52025V16.7495C19.6513 17.1441 19.5034 17.3273 19.3538 17.4355C19.2194 17.5325 19.054 17.5863 18.915 17.6088C18.8632 17.6169 18.815 17.6209 18.7734 17.6209H5.22662C4.82072 17.6209 4.63479 17.4656 4.52898 17.3179C4.46543 17.229 4.42108 17.1271 4.39221 17.0288C4.36181 16.9251 4.34875 16.8254 4.34875 16.7495V6.25047Z" />
|
||||
</vector>
|
||||
20
app/src/main/res/drawable/gear_outlined.xml
Normal file
17
app/src/main/res/drawable/night_mode.xml
Normal file
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
|
||||
<path
|
||||
android:fillColor="#50A7EA"
|
||||
android:pathData="M16.7788 6.57113C16.7788 11.2065 12.999 14.9642 8.33642 14.9642C6.62297 14.9642 5.02869 14.4567 3.69744 13.5847C3.35325 13.3592 2.88114 13.6576 3.0273 14.0408C4.35608 17.524 7.74386 20 11.7134 20C16.8422 20 21 15.8665 21 10.7677C21 7.55163 19.3459 4.71968 16.8374 3.0667C16.4935 2.84003 16.0957 3.233 16.241 3.61702C16.5886 4.5359 16.7788 5.53145 16.7788 6.57113Z" />
|
||||
<path
|
||||
android:fillColor="#50A7EA"
|
||||
android:pathData="M6.08512 4.05322C6.31025 4.05322 6.36653 4.23126 6.36653 4.32027C6.36653 4.76536 6.64794 5.65553 7.7736 5.65553C7.8674 5.65553 8.05501 5.70894 8.05501 5.92258C8.05501 6.13622 7.8674 6.18963 7.7736 6.18963C7.39838 6.18963 6.36653 6.45668 6.36653 7.52489C6.36653 7.61391 6.31025 7.79194 6.08512 7.79194C5.85999 7.79194 5.80371 7.61391 5.80371 7.52489C5.80371 6.5635 4.86566 6.23414 4.39664 6.18963C4.30284 6.18963 4.11523 6.13622 4.11523 5.92258C4.11523 5.70894 4.30284 5.65553 4.39664 5.65553C5.52229 5.65553 5.80371 4.76536 5.80371 4.32027C5.80371 4.23126 5.85999 4.05322 6.08512 4.05322Z" />
|
||||
<path
|
||||
android:fillColor="#50A7EA"
|
||||
android:pathData="M10.3063 7.25784C10.0812 7.25784 10.0249 7.43587 10.0249 7.52489C10.0249 8.16581 9.46207 8.32604 9.18066 8.32604C9.08686 8.32604 8.89925 8.37945 8.89925 8.59309C8.89925 8.80674 9.08686 8.86015 9.18066 8.86015C9.85605 8.86015 10.0249 9.39425 10.0249 9.6613C10.0249 9.75032 10.0812 9.92835 10.3063 9.92835C10.5314 9.92835 10.5877 9.75032 10.5877 9.6613C10.5877 9.02038 11.1505 8.86015 11.432 8.86015C11.5258 8.86015 11.7134 8.80674 11.7134 8.59309C11.7134 8.37945 11.5258 8.32604 11.432 8.32604C10.7566 8.32604 10.5877 7.79194 10.5877 7.52489C10.5877 7.43587 10.5314 7.25784 10.3063 7.25784Z" />
|
||||
</vector>
|
||||