From 93ce53d3d56fc1f3a5c06f542eb68d76c7fc02ee Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Fri, 13 Feb 2026 17:37:03 +0500 Subject: [PATCH] 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. --- .../com/rosetta/messenger/MainActivity.kt | 20 +- .../messenger/data/PreferencesManager.kt | 24 +- .../messenger/ui/chats/ChatsListScreen.kt | 341 +++++++++-- .../ui/components/metaball/CpuBlurUtils.kt | 5 +- .../components/metaball/ProfileGooeyEffect.kt | 136 +++++ .../components/metaball/ProfileGooeyView.java | 544 ++++++++++++++++++ .../messenger/ui/settings/ProfileScreen.kt | 221 ++++--- .../res/drawable-hdpi/left_status_profile.png | Bin 0 -> 538 bytes .../main/res/drawable-hdpi/msg_archive.png | Bin 0 -> 384 bytes app/src/main/res/drawable-hdpi/msg_saved.png | Bin 0 -> 321 bytes .../res/drawable-hdpi/msg_settings_old.png | Bin 0 -> 604 bytes .../res/drawable-mdpi/left_status_profile.png | Bin 0 -> 381 bytes .../main/res/drawable-mdpi/msg_archive.png | Bin 0 -> 274 bytes app/src/main/res/drawable-mdpi/msg_saved.png | Bin 0 -> 188 bytes .../res/drawable-mdpi/msg_settings_old.png | Bin 0 -> 411 bytes .../drawable-xhdpi/left_status_profile.png | Bin 0 -> 622 bytes .../main/res/drawable-xhdpi/msg_archive.png | Bin 0 -> 373 bytes app/src/main/res/drawable-xhdpi/msg_saved.png | Bin 0 -> 302 bytes .../res/drawable-xhdpi/msg_settings_old.png | Bin 0 -> 669 bytes .../drawable-xxhdpi/left_status_profile.png | Bin 0 -> 877 bytes .../main/res/drawable-xxhdpi/msg_archive.png | Bin 0 -> 463 bytes .../main/res/drawable-xxhdpi/msg_saved.png | Bin 0 -> 356 bytes .../res/drawable-xxhdpi/msg_settings_old.png | Bin 0 -> 798 bytes .../res/drawable/account_circle_outlined.xml | 16 + app/src/main/res/drawable/archive_filled.xml | 12 + .../main/res/drawable/bookmark_outlined.xml | 12 + .../main/res/drawable/day_theme_filled.xml | 36 ++ app/src/main/res/drawable/folder_outlined.xml | 12 + app/src/main/res/drawable/gear_outlined.xml | 20 + app/src/main/res/drawable/night_mode.xml | 17 + 30 files changed, 1269 insertions(+), 147 deletions(-) create mode 100644 app/src/main/java/com/rosetta/messenger/ui/components/metaball/ProfileGooeyEffect.kt create mode 100644 app/src/main/java/com/rosetta/messenger/ui/components/metaball/ProfileGooeyView.java create mode 100644 app/src/main/res/drawable-hdpi/left_status_profile.png create mode 100644 app/src/main/res/drawable-hdpi/msg_archive.png create mode 100644 app/src/main/res/drawable-hdpi/msg_saved.png create mode 100644 app/src/main/res/drawable-hdpi/msg_settings_old.png create mode 100644 app/src/main/res/drawable-mdpi/left_status_profile.png create mode 100644 app/src/main/res/drawable-mdpi/msg_archive.png create mode 100644 app/src/main/res/drawable-mdpi/msg_saved.png create mode 100644 app/src/main/res/drawable-mdpi/msg_settings_old.png create mode 100644 app/src/main/res/drawable-xhdpi/left_status_profile.png create mode 100644 app/src/main/res/drawable-xhdpi/msg_archive.png create mode 100644 app/src/main/res/drawable-xhdpi/msg_saved.png create mode 100644 app/src/main/res/drawable-xhdpi/msg_settings_old.png create mode 100644 app/src/main/res/drawable-xxhdpi/left_status_profile.png create mode 100644 app/src/main/res/drawable-xxhdpi/msg_archive.png create mode 100644 app/src/main/res/drawable-xxhdpi/msg_saved.png create mode 100644 app/src/main/res/drawable-xxhdpi/msg_settings_old.png create mode 100644 app/src/main/res/drawable/account_circle_outlined.xml create mode 100644 app/src/main/res/drawable/archive_filled.xml create mode 100644 app/src/main/res/drawable/bookmark_outlined.xml create mode 100644 app/src/main/res/drawable/day_theme_filled.xml create mode 100644 app/src/main/res/drawable/folder_outlined.xml create mode 100644 app/src/main/res/drawable/gear_outlined.xml create mode 100644 app/src/main/res/drawable/night_mode.xml diff --git a/app/src/main/java/com/rosetta/messenger/MainActivity.kt b/app/src/main/java/com/rosetta/messenger/MainActivity.kt index 7fbfa02..4cec32c 100644 --- a/app/src/main/java/com/rosetta/messenger/MainActivity.kt +++ b/app/src/main/java/com/rosetta/messenger/MainActivity.kt @@ -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( diff --git a/app/src/main/java/com/rosetta/messenger/data/PreferencesManager.kt b/app/src/main/java/com/rosetta/messenger/data/PreferencesManager.kt index 0d2ca3a..68cac49 100644 --- a/app/src/main/java/com/rosetta/messenger/data/PreferencesManager.kt +++ b/app/src/main/java/com/rosetta/messenger/data/PreferencesManager.kt @@ -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 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 = - 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 = _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 } // ═════════════════════════════════════════════════════════════ diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt index 929b013..d131b03 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt @@ -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>(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 + // � 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 + + // ═══════════════════════════════════════════════════════════ + // �📱 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( } ) - // � 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( } ) - // �📖 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) ) diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/metaball/CpuBlurUtils.kt b/app/src/main/java/com/rosetta/messenger/ui/components/metaball/CpuBlurUtils.kt index 358de1f..d287cd9 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/components/metaball/CpuBlurUtils.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/components/metaball/CpuBlurUtils.kt @@ -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 diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/metaball/ProfileGooeyEffect.kt b/app/src/main/java/com/rosetta/messenger/ui/components/metaball/ProfileGooeyEffect.kt new file mode 100644 index 0000000..abcfb68 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/ui/components/metaball/ProfileGooeyEffect.kt @@ -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() + } + ) +} diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/metaball/ProfileGooeyView.java b/app/src/main/java/com/rosetta/messenger/ui/components/metaball/ProfileGooeyView.java new file mode 100644 index 0000000..ba82f9d --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/ui/components/metaball/ProfileGooeyView.java @@ -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); + } +} diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt index 0bf7c62..fb4fa06 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt @@ -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( } // ═══════════════════════════════════════════════════════════ - // � 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) - ) - } - } - - // ═══════════════════════════════════════════════════════════ - // �🔙 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) ) } } diff --git a/app/src/main/res/drawable-hdpi/left_status_profile.png b/app/src/main/res/drawable-hdpi/left_status_profile.png new file mode 100644 index 0000000000000000000000000000000000000000..018fb70e70c37c4b37bc50b80812ae810bd21c9a GIT binary patch literal 538 zcmeAS@N?(olHy`uVBq!ia0vp^Dj>|k3?#4J%UA`ZSkfJR9T^xl_H+M9WCijWi-X*q z7}lMWc?sm01^9%xx)=ETCmpl}orwmTAXpOQ7tBy1b(p8qOwHlu$DgYgKaH5$G(B!U z(5h5V7sn8b({n@KZfjBC5V>Na8-4$Os{W78tM`h}KRv(AlE;8oYb(ny?gyV%>^qkB z{Z7*M+tx16Rlf+Q^%XJPlYDG5rRDG%3HR1TqH7oQhFtON;h$3eTCCH$;-IrcMHy#{ zR7_h|Z2sn$kd5C4EGGn&q z+l#(c$DJ;FiXT0CPj2NUW8TNC9oFgM8#k|+q!8FAdBSjFuc^J=bm89O=$;dS5sT(L zekH_`%^1Fafyigmt{G10d7F=>tTR5t&tvr^v{S0*&w;yIdGX;|xremgK31;OoO|KX zlAaBo)*`<*HKfhW691;#ov{4;vOn@0IZ~HC=`o!e&$eAJeTRN})b!0ow%2WTM9-^> mlaTsf{PoXAq2Euf{xHtgkf@hY`C1Q*4F*qFKbLh*2~7Y>4+h2n literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-hdpi/msg_archive.png b/app/src/main/res/drawable-hdpi/msg_archive.png new file mode 100644 index 0000000000000000000000000000000000000000..f62e558347a139f4c5522508116a7dd532fe0fcd GIT binary patch literal 384 zcmeAS@N?(olHy`uVBq!ia0vp^Dj>|l3?zm1T2})pmUKs7M+SzC{oH>NS%G}U;vjb? zhIQv;UIIC~0X`wF?gc*oi3Vrm?lJ*2@|6Vn1v4~q{XFckQcmx2e_q(kYWb8OK$+{F zE{-7@=aUl@q+W11HIy*>v?SlN)~MhXepSQH&M6@Ht&)FX(VM#U3OrNGnGC*az2|Gl z+^ODVppcr*5|;a&kF!BHp}L((q`~P-KevEhNcDC`k59IaMJCoaBx}}Bu>F+B=shh&%5e)S^%w?2U_A@|N!|FK|j?Kt`V zu63}9bm+ZCQAKXUpSDK}#QhoD1d1LM>~rM|4RF|OYu6&6RmtupCZjR&8;_1~r_K}> ku_UgRaQZf7wnmL(0MLsJp00i_>zopr0B04Q0RR91 literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-hdpi/msg_saved.png b/app/src/main/res/drawable-hdpi/msg_saved.png new file mode 100644 index 0000000000000000000000000000000000000000..c487a1ed79ec0c955d7ebf9b3332596a7e645f70 GIT binary patch literal 321 zcmeAS@N?(olHy`uVBq!ia0vp^Dj>|l3?zm1T2})pmUKs7M+SzC{oH>NS%G}U;vjb? zhIQv;UIIA=0X`wF?gc*oNdYc)Z}t$5BJz*M0__kWc>^&vX>kvKpNI zx}PC$O%210q(A)|JpUbDAz<42>5b5ZmPyb5&!5wvbVc^RyiS|ItAGFhb9CDU@aR4H z{Qo~E8{32#wmvWZUzb&23wZONJ>$y1Ee#fo8f=|WeY4XH(l@@4b_ixVwDL`cNge}3 Y=GvUMQ+;%#f$n4QboFyt=akR{0KAKqP5=M^ literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-hdpi/msg_settings_old.png b/app/src/main/res/drawable-hdpi/msg_settings_old.png new file mode 100644 index 0000000000000000000000000000000000000000..ceddf276e132a9086d49812173f6eba77df60d61 GIT binary patch literal 604 zcmeAS@N?(olHy`uVBq!ia0vp^Dj>|k3?#4J%UA`ZSkfJR9T^xl_H+M9WCijWi-X*q z7}lMWc?sm$2l#}zx)=ETrvP~M&XNEcB~lXP7tHW|{#&++tLxQd9_|lv5Qxj`H9478 z+?NitcDARBV~EA++7QpCW&@tnZebjpSO4F)Iu~cP)J`O;N!Qz`=k6nsMy2R)G9O#T z{?yp1F!;B|$^6+V)*kq@isANUN$xLZa?egb)_wnR%7J_TvK=gT=Pr**-_|F7@T;O_ zqdw=`XLm9*_oo%H>8`9^tMO3R`s=cLf-#O3)1I?bI{lQF+GAEClbCvU=hEb5u zwP!W8MNXLbOBRIcSM5_jJ|j)!He>YG2^+63G=9{vO+f7i%bTd^{ogOm{isyZD&Tq6 zfG=G1PSyQSWzqpRR!&r@c>G1;1yfCOLZBb>s$bGKTMo^>u-Gh^Wtm7$b4mKi-O_8^ zYv0fC)AV;Pcb2>KFf=W&Xp-Yx?fISMAKeyxtG+q!Q?KuG#n9aK)7c&Xl6O}rm```O5;qw us&-0G?f;LWw|~!3+7zF-^7dEGAN)SS(kqV7P&EQZBZH@_pUXO@geCygAuEpn literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-mdpi/left_status_profile.png b/app/src/main/res/drawable-mdpi/left_status_profile.png new file mode 100644 index 0000000000000000000000000000000000000000..09e4306cf42db378a96923d21cc8b5c6aba2a038 GIT binary patch literal 381 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM3?#3wJbMaA*#!86xVjhk{3jRmpWN^jXpV46 zkY6yv-_F!DHGX?4k+-jgl4=m`~H7_ zn}_qslvu~V3;eFV-lu=|%k;GdJbKc;lNwx|y&PK=GgCSzTo88@FqYK(~`C1tsrPt&84^8`jWM}T%)Y2USsbassTR3juPY=>C72!9Xw=Zayz&~jw zy(S}jz{gcvHvZ$uPdYHO ZhM}f{Tau4ul0DF)44$rjF6*2UngG=4y!rqD literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-mdpi/msg_archive.png b/app/src/main/res/drawable-mdpi/msg_archive.png new file mode 100644 index 0000000000000000000000000000000000000000..7f981d8d5fc22e3ed2af90587fff95b3ec01d9d5 GIT binary patch literal 274 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM3?#3wJbMaA=?3_OxVjhk{3jZmjl0VP)W}y7 zZF zoT&f9puosblsoeot0s@uBSj;jso6L37`dEwS9Y|`>fBd;L9fTTc8_`igqZ*U literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-mdpi/msg_saved.png b/app/src/main/res/drawable-mdpi/msg_saved.png new file mode 100644 index 0000000000000000000000000000000000000000..8dd52dce8a865c4a74a62399dcc5e6ae6907d0a3 GIT binary patch literal 188 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM0wlfaz7_+iW=|K#kcwMxFPJm78i+VtG- zYnVLKph3PlChajJ|BAyoY$+Oo9DBkXOf47%{a0O-3Fyk&aJVBuGs}dR;m`vWQ7HzV lX;^Q0Ku`bx literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-mdpi/msg_settings_old.png b/app/src/main/res/drawable-mdpi/msg_settings_old.png new file mode 100644 index 0000000000000000000000000000000000000000..6f7a1c676cedd84a1f0b86dd812814f5f5ebd5b6 GIT binary patch literal 411 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM3?#3wJbMaAIR*HHxVjhk{HF|j&7IH>G)=T5 z$S;`Tt2rVAysZ35 zrSqr14U|1?TJ`muY`nz7aFffsI1*5*Q( zwam`q&(%r%HHwv1eRmJV=a_tIkngLVx4wbBzEqfd&I++u8KCzWJYD@<);T3K0RT~w B+gShr literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xhdpi/left_status_profile.png b/app/src/main/res/drawable-xhdpi/left_status_profile.png new file mode 100644 index 0000000000000000000000000000000000000000..e9a5f93b9c02d458767c713564ea8a3e92596ad3 GIT binary patch literal 622 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA3?vioaBc-smH|E?uI>ds|H%TiHq(58h6t4e z`2{m@iKyr~`GloaHBVi-`S{JJUstL<3S?klyyWTP7*cU-PH1}>iy}u%&h41n|Lb$+ zWz0#~<2^IvMzloAfm~LjYnIs=zj;sGU0Jf^_TMj)YKsI<^xus;Y?In}JSSTtUgG1u zwQ85j_9uU?*vd11#<$Ft&pG#8C)tJ{6F*zG>&HR6-~MU4eb(;SRNNI`E1uK2+D_Ec zCoXkr&F03nmt+_jnpqCjJ$hWDrCMpe@l$iaTJiHG8nTXuqqWU{*elOHo2n3f;hgd+ z(Uy|5tFr9b2APhh>eM_`f~w6Bn|4l?6tVSsl-A zj6JJ7cj0Z3lntluJ!B0DTlDb9#s@YZm^4fU`0Kvx`@1xE*_Cd#5DjL=Su(DtlaJo; z*JeE4b&T=V2gM)E4=-AHJl0j(;k@*d~y_$NpN3ZF7}vF_KH z)^IwtmD$^2&CG53m$u$+{M%FOopd%~VxZJ#{tKZN_F|G@Pj}us?mkr^Otodc@D{c& zLK}M&C%cs~D$if2ePL&dmgw{U>vdNA{kk;b&M&>{%D1oj1A{gS=UD~Zuu$CjlbKds|A_x+*s=&We?>l2cY;wXGzT_;z-W$Tz(jNj%SYt&|V&fcg$ zN1kQhhLW}WHniIN?VZmaZG3l=LZa!5bAlUG0=^gOD*Sq}o%2%cIUU3059(Vc8t<+S kY-zdp$^EYVyZNWZXRngYbll;71?X`GPgg&ebxsLQ01CFTy8r+H literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xhdpi/msg_saved.png b/app/src/main/res/drawable-xhdpi/msg_saved.png new file mode 100644 index 0000000000000000000000000000000000000000..bfb65c7b1f634f254c077ed1cf206e5755e18e61 GIT binary patch literal 302 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA3?vioaBc-sS^+*GuI>ds|A_NK#66ZE{-7;x89y(UFU2l;2hX~@pt^Y<973woZ0*6 zguCz^liWEoOeX|2p6xoSHut7>jzh}+ue{Dik{z1iC!10m*GR3E@!b3~(8<{MQl5j0 z^`v*qb`OAPpTp|phR&&`H=P?j|LEU#ZjgP({E~V0&uHFb(;G_~q@PJO%o3F3DPI5a zzQ-Jnf7)@0Sqers&af*reyFdS!F?oQqNaT@2d_$=oN1M&P2T+#mg3B>MWjG3X7F_N Kb6Mw<&;$VP8;RTi literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xhdpi/msg_settings_old.png b/app/src/main/res/drawable-xhdpi/msg_settings_old.png new file mode 100644 index 0000000000000000000000000000000000000000..07690b8ac0c6f20f98f87ef435eb7ca8af32354e GIT binary patch literal 669 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA3?vioaBc-smH|E?uI>ds|H%TiHq(58h6t4e z`2{nu3CQSJc|~NDb*k5wG?XO~pn(Y+IQb-C3Wh8fcY8^Vw9ELdsIec;o1OUreIM}&_ppTe;0w)LSn zqfK*uxNc}rui!h?y`&_ zsQR{}+U@VVUY^kslF~a?r<$@MPicFd!8z-NdV81F{nb6z=(Yan)`gef9&79S_H9qj z=I(yxh@R-UTJ7H!+cnlqC}L5qFl}JDxz8b`@%5f1szT9fVZ8IXqY_089$meD=lkK8 z^ZzwY_TO=$Bm{b@%UHx3vIVCg!06YRnHUIzs literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xxhdpi/left_status_profile.png b/app/src/main/res/drawable-xxhdpi/left_status_profile.png new file mode 100644 index 0000000000000000000000000000000000000000..1569ec941b555f2fb2249659875ca975af6c5960 GIT binary patch literal 877 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY3?!3`olAj~aez;Vt9yaZe-gp@i(QF8?E)o1 ze!&bZ0#aI50SRU8lUMJ*`tp0lw#JhT3`{pYT^vIyZtaO^-?m0U;I?Dc|NrxAUGKbc zT(tfEoK37}cp5c-{1D9$57OMaHOiaIufEG=XUK0(Q_HW8QnRC$XPm5SS@uO*@M#d*`gA8br!A3b7fDRUeYMExBtAU)wUf*)+Ip>+rxiw zIcljmoIhRWERo6az|F$3CFxws&1l-N@o6>;*{j{%6r$5q%`^M zN2*e*XMZ@O^!44wGsjY|H=K+;X)K|pZYw-lzs!A};BFT|mklko;+h+je={oC%)ilT zdbxA1(t!;e9^s3l|M&O)uT0#$>7t|j38hobH`n+pxlRtdeNE@pbdg^wx7t%TUOxBK zZEC0Vgum}QJDyx@dc2%_(G20l%sp}2GRj<&ABzflCnqT@@@@(GAj&7SapT`TEKC~n zOMbs>oRh++AYnVf#D(GGR_3eRGn<}gUhup7ZJmFz>^G4uHoZOpQRh``dOsW~FjI{cBw9n zC7lYnyzd03pXl0dnz2Pe(qv|1MPPMncslRd o?Kqc{_b+B=^n6H=wPj#FVdQ&MBb@0K7A@q5uE@ literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable-xxhdpi/msg_archive.png b/app/src/main/res/drawable-xxhdpi/msg_archive.png new file mode 100644 index 0000000000000000000000000000000000000000..829dd185d6f24449fec4c2bc8b4e1c2290fa9e4a GIT binary patch literal 463 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY3?!3`olAj~N`Oy@t9yaZe?mZ{DmERcp1UN- zFPMQ($t7d*_M?wK|NhXj+K7RH(cja>F{I+wo9pa-4-|M>6LYNg{jUx0cr*Li6!X1L z!uzNCYkD*le4m=R?r@*f+9L&Va%I;fluxfrzOHr1x-PV$ed>cbH4L7~o{8(X%n>=) z<$Y>*vc>YC?U5zzw`8CHn7ONJ^DEnTC(~E_YdKS2_tLFpR)Ka$h03SK0QLPB>Ms6F zRnFWoZnZ}lu6n?H8^DRr4ia%AX-#X=cL)*;W?amT+FYwlC zs%Ub$wRxMUoL%#B^2BrAhn!C4cuM#l*ZFv1l0g(7zvplLwHKd#Sl?!SHH~X}o5D>V zK7L21f(VXD?*#-@ENoa#mS=DX$;dT5v6ca{Ei^pVMZUXxV%yoUOM&Uex6b6K2zh;8 z_H2&Atbjg~=N6*5*E(c(FM0NE7L|&)$(GUHw^5UAu?AN>gOtO3 zwhV?T7aY>r3>Ypf4R%~ztvcb2C)2y4up&m@xU%w1`&3Ta2O1n^xvRF!^lFEddG3@A zHi1)wwlB|b&bi5Dy6(`@H(?f=f_T&KoXBZ+)!y!>wwieoL+`}XuUI0hb~=f!3TV~( zl6=MD?q9_$zn!sv7`Dz~y!mK%#sao=Nl%W2w%nPsgX`jj$(CCx!x^QR5{{n74Y{+%3&F>!zOXjkw#I{VM8A;^ljHr&U>R)jG24@5^m>PM2wEy4?D=GOP`*c8SryT$LTdOS?ph?Nz}>DoZPtm7$WzZgR^I)2=Z@*ABaQ`fQYVkRS)Xm{ z&iwX;>qW8884hb^MBFo2z577%i5g*}Nf(ZCZ77c7o@#n_!rZvq{*kYmUMb7?)O)>K z)6>lsH7WkeL?b#EIeAC<^!n5Z)slS4)-wN^G@a(3Sv==ujSTO-tk;MA zyd(QRnH{cpmRZty#kIbpzpl5r^9*04=7ZxhrsX!vc9|Hr=doKYI>@xts5EnJoMO$} zQz=?^rk4Fn0R~v9Sx(pyKUo1*-7iBdk8<_v%Ht3e?4li$j+;M-`|{et=76;z3t8)$>m&pDN=d= zB<45>#jif}mdKI;Vst E0CmQfTL1t6 literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable/account_circle_outlined.xml b/app/src/main/res/drawable/account_circle_outlined.xml new file mode 100644 index 0000000..a3ef0fd --- /dev/null +++ b/app/src/main/res/drawable/account_circle_outlined.xml @@ -0,0 +1,16 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/archive_filled.xml b/app/src/main/res/drawable/archive_filled.xml new file mode 100644 index 0000000..5d92846 --- /dev/null +++ b/app/src/main/res/drawable/archive_filled.xml @@ -0,0 +1,12 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bookmark_outlined.xml b/app/src/main/res/drawable/bookmark_outlined.xml new file mode 100644 index 0000000..9b995d8 --- /dev/null +++ b/app/src/main/res/drawable/bookmark_outlined.xml @@ -0,0 +1,12 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/day_theme_filled.xml b/app/src/main/res/drawable/day_theme_filled.xml new file mode 100644 index 0000000..e4c79d7 --- /dev/null +++ b/app/src/main/res/drawable/day_theme_filled.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/folder_outlined.xml b/app/src/main/res/drawable/folder_outlined.xml new file mode 100644 index 0000000..a556ce4 --- /dev/null +++ b/app/src/main/res/drawable/folder_outlined.xml @@ -0,0 +1,12 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/gear_outlined.xml b/app/src/main/res/drawable/gear_outlined.xml new file mode 100644 index 0000000..4c240e2 --- /dev/null +++ b/app/src/main/res/drawable/gear_outlined.xml @@ -0,0 +1,20 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/night_mode.xml b/app/src/main/res/drawable/night_mode.xml new file mode 100644 index 0000000..1292800 --- /dev/null +++ b/app/src/main/res/drawable/night_mode.xml @@ -0,0 +1,17 @@ + + + + + + + \ No newline at end of file