feat: Add appearance customization screen with background blur options

- Introduced `BackgroundBlurOption` data class and `BackgroundBlurPresets` object for managing background blur options.
- Created `AppearanceScreen` composable for selecting background colors and gradients, including a live preview of the selected option.
- Updated `OtherProfileScreen` and `ProfileScreen` to accept and utilize `backgroundBlurColorId` for consistent background blur across profiles.
- Enhanced `CollapsingOtherProfileHeader` and `CollapsingProfileHeader` to apply selected background blur options.
This commit is contained in:
2026-02-07 08:10:26 +05:00
parent eef254a9cf
commit 71181f49d9
9 changed files with 1864 additions and 661 deletions

View File

@@ -542,11 +542,16 @@ fun MainScreen(
var showLogsScreen by remember { mutableStateOf(false) }
var showCrashLogsScreen by remember { mutableStateOf(false) }
var showBiometricScreen by remember { mutableStateOf(false) }
var showAppearanceScreen by remember { mutableStateOf(false) }
// ProfileViewModel для логов
val profileViewModel: com.rosetta.messenger.ui.settings.ProfileViewModel = androidx.lifecycle.viewmodel.compose.viewModel()
val profileState by profileViewModel.state.collectAsState()
// Appearance: background blur color preference
val prefsManager = remember { com.rosetta.messenger.data.PreferencesManager(context) }
val backgroundBlurColorId by prefsManager.backgroundBlurColorId.collectAsState(initial = "avatar")
// AvatarRepository для работы с аватарами
val avatarRepository = remember(accountPublicKey) {
if (accountPublicKey.isNotBlank() && accountPublicKey != "04c266b98ae5") {
@@ -608,10 +613,61 @@ fun MainScreen(
// TODO: Show new chat screen
},
onUserSelect = { selectedChatUser -> selectedUser = selectedChatUser },
backgroundBlurColorId = backgroundBlurColorId,
avatarRepository = avatarRepository,
onLogout = onLogout
)
// ═══════════════════════════════════════════════════════════
// Profile Screen — MUST be before sub-screens so it stays
// visible beneath them during swipe-back animation
// ═══════════════════════════════════════════════════════════
SwipeBackContainer(
isVisible = showProfileScreen,
onBack = { showProfileScreen = false },
isDarkTheme = isDarkTheme
) {
// Экран профиля
ProfileScreen(
isDarkTheme = isDarkTheme,
accountName = accountName,
accountUsername = accountUsername,
accountPublicKey = accountPublicKey,
accountPrivateKeyHash = privateKeyHash,
onBack = { showProfileScreen = false },
onSaveProfile = { name, username ->
accountName = name
accountUsername = username
mainScreenScope.launch {
onAccountInfoUpdated()
}
},
onLogout = onLogout,
onNavigateToTheme = {
showThemeScreen = true
},
onNavigateToAppearance = {
showAppearanceScreen = true
},
onNavigateToSafety = {
showSafetyScreen = true
},
onNavigateToLogs = {
showLogsScreen = true
},
onNavigateToCrashLogs = {
showCrashLogsScreen = true
},
onNavigateToBiometric = {
showBiometricScreen = true
},
viewModel = profileViewModel,
avatarRepository = avatarRepository,
dialogDao = RosettaDatabase.getDatabase(context).dialogDao(),
backgroundBlurColorId = backgroundBlurColorId
)
}
// Other screens with swipe back
SwipeBackContainer(
isVisible = showBackupScreen,
@@ -705,6 +761,33 @@ fun MainScreen(
)
}
SwipeBackContainer(
isVisible = showAppearanceScreen,
onBack = {
showAppearanceScreen = false
showProfileScreen = true
},
isDarkTheme = isDarkTheme
) {
com.rosetta.messenger.ui.settings.AppearanceScreen(
isDarkTheme = isDarkTheme,
currentBlurColorId = backgroundBlurColorId,
onBack = {
showAppearanceScreen = false
showProfileScreen = true
},
onBlurColorChange = { newId ->
mainScreenScope.launch {
prefsManager.setBackgroundBlurColorId(newId)
}
},
onToggleTheme = onToggleTheme,
accountPublicKey = accountPublicKey,
accountName = accountName,
avatarRepository = avatarRepository
)
}
SwipeBackContainer(
isVisible = showUpdatesScreen,
onBack = { showUpdatesScreen = false },
@@ -762,58 +845,6 @@ fun MainScreen(
)
}
SwipeBackContainer(
isVisible = showProfileScreen,
onBack = { showProfileScreen = false },
isDarkTheme = isDarkTheme
) {
// Экран профиля
ProfileScreen(
isDarkTheme = isDarkTheme,
accountName = accountName,
accountUsername = accountUsername,
accountPublicKey = accountPublicKey,
accountPrivateKeyHash = privateKeyHash,
onBack = { showProfileScreen = false },
onSaveProfile = { name, username ->
// Following desktop version pattern:
// 1. Server confirms save (handled in ProfileViewModel)
// 2. Local DB updated (handled in ProfileScreen LaunchedEffect)
// 3. This callback updates UI state immediately
accountName = name
accountUsername = username
// Reload account list so auth screen shows updated name
mainScreenScope.launch {
onAccountInfoUpdated()
}
},
onLogout = onLogout,
onNavigateToTheme = {
showProfileScreen = false
showThemeScreen = true
},
onNavigateToSafety = {
showProfileScreen = false
showSafetyScreen = true
},
onNavigateToLogs = {
showProfileScreen = false
showLogsScreen = true
},
onNavigateToCrashLogs = {
showProfileScreen = false
showCrashLogsScreen = true
},
onNavigateToBiometric = {
showProfileScreen = false
showBiometricScreen = true
},
viewModel = profileViewModel,
avatarRepository = avatarRepository,
dialogDao = RosettaDatabase.getDatabase(context).dialogDao()
)
}
SwipeBackContainer(
isVisible = showLogsScreen,
onBack = {
@@ -867,7 +898,8 @@ fun MainScreen(
showOtherProfileScreen = false
selectedOtherUser = null
},
avatarRepository = avatarRepository
avatarRepository = avatarRepository,
backgroundBlurColorId = backgroundBlurColorId
)
}
}

View File

@@ -42,6 +42,9 @@ class PreferencesManager(private val context: Context) {
// Language
val APP_LANGUAGE = stringPreferencesKey("app_language") // "en", "ru", etc.
// Appearance / Customization
val BACKGROUND_BLUR_COLOR_ID = stringPreferencesKey("background_blur_color_id") // id from BackgroundBlurPresets
}
// ═════════════════════════════════════════════════════════════
@@ -189,4 +192,17 @@ class PreferencesManager(private val context: Context) {
suspend fun setAppLanguage(value: String) {
context.dataStore.edit { preferences -> preferences[APP_LANGUAGE] = value }
}
// ═════════════════════════════════════════════════════════════
// 🎨 APPEARANCE / CUSTOMIZATION
// ═════════════════════════════════════════════════════════════
val backgroundBlurColorId: Flow<String> =
context.dataStore.data.map { preferences ->
preferences[BACKGROUND_BLUR_COLOR_ID] ?: "avatar" // Default: use avatar blur
}
suspend fun setBackgroundBlurColorId(value: String) {
context.dataStore.edit { preferences -> preferences[BACKGROUND_BLUR_COLOR_ID] = value }
}
}

View File

@@ -49,6 +49,7 @@ import com.rosetta.messenger.ui.components.AppleEmojiText
import com.rosetta.messenger.ui.components.AvatarImage
import com.rosetta.messenger.ui.components.BlurredAvatarBackground
import com.rosetta.messenger.ui.components.VerifiedBadge
import com.rosetta.messenger.ui.settings.BackgroundBlurPresets
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import java.text.SimpleDateFormat
import java.util.*
@@ -167,6 +168,7 @@ fun ChatsListScreen(
onSearchClick: () -> Unit,
onNewChat: () -> Unit,
onUserSelect: (com.rosetta.messenger.network.SearchUser) -> Unit = {},
backgroundBlurColorId: String = "avatar",
chatsViewModel: ChatsListViewModel = androidx.lifecycle.viewmodel.compose.viewModel(),
avatarRepository: com.rosetta.messenger.repository.AvatarRepository? = null,
onLogout: () -> Unit
@@ -453,7 +455,8 @@ android.util.Log.d("ChatsListScreen", "✅ Total LaunchedEffect: ${System.curren
avatarRepository = avatarRepository,
fallbackColor = headerColor,
blurRadius = 40f,
alpha = 0.6f
alpha = 0.6f,
overlayColors = BackgroundBlurPresets.getOverlayColors(backgroundBlurColorId)
)
// Content поверх фона

View File

@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.graphicsLayer
@@ -28,6 +29,8 @@ import kotlinx.coroutines.withContext
* @param fallbackColor Цвет фона если нет аватарки
* @param blurRadius Радиус размытия (в пикселях) - применяется при обработке
* @param alpha Прозрачность (0.0 - 1.0)
* @param overlayColors Опциональные цвета overlay поверх blur. Если null — стандартное поведение.
* Если 1 цвет — сплошной overlay, если 2+ — градиент.
*/
@Composable
fun BoxScope.BlurredAvatarBackground(
@@ -35,7 +38,8 @@ fun BoxScope.BlurredAvatarBackground(
avatarRepository: AvatarRepository?,
fallbackColor: Color,
blurRadius: Float = 25f,
alpha: Float = 0.3f
alpha: Float = 0.3f,
overlayColors: List<Color>? = null
) {
// Получаем аватары из репозитория
val avatars by avatarRepository?.getAvatars(publicKey, allDecode = false)?.collectAsState()
@@ -91,19 +95,55 @@ fun BoxScope.BlurredAvatarBackground(
contentScale = ContentScale.Crop
)
// Дополнительный overlay для затемнения
Box(
modifier = Modifier
.fillMaxSize()
.background(fallbackColor.copy(alpha = 0.3f))
)
// Дополнительный overlay — кастомный или стандартный
if (overlayColors != null && overlayColors.isNotEmpty()) {
// Кастомный цветной overlay
val overlayModifier = if (overlayColors.size == 1) {
Modifier
.fillMaxSize()
.background(overlayColors[0].copy(alpha = 0.55f))
} else {
Modifier
.fillMaxSize()
.background(
Brush.linearGradient(
colors = overlayColors.map { it.copy(alpha = 0.55f) }
)
)
}
Box(modifier = overlayModifier)
} else {
// Стандартный overlay для затемнения
Box(
modifier = Modifier
.fillMaxSize()
.background(fallbackColor.copy(alpha = 0.3f))
)
}
} else {
// Fallback: цветной фон
Box(
modifier = Modifier
.fillMaxSize()
.background(fallbackColor)
)
// Fallback: когда нет аватарки
if (overlayColors != null && overlayColors.isNotEmpty()) {
// Кастомный фон без blur
val bgModifier = if (overlayColors.size == 1) {
Modifier
.fillMaxSize()
.background(overlayColors[0])
} else {
Modifier
.fillMaxSize()
.background(
Brush.linearGradient(colors = overlayColors)
)
}
Box(modifier = bgModifier)
} else {
// Стандартный fallback: цветной фон
Box(
modifier = Modifier
.fillMaxSize()
.background(fallbackColor)
)
}
}
}
}

View File

@@ -0,0 +1,75 @@
package com.rosetta.messenger.ui.settings
import androidx.compose.ui.graphics.Color
/**
* Модель для варианта фона blur в профиле
* @param id Уникальный идентификатор (сохраняется в PreferencesManager)
* @param colors Список цветов. Если 1 — сплошной цвет, если 2+ — градиент
* @param label Человекочитаемое название
*/
data class BackgroundBlurOption(
val id: String,
val colors: List<Color>,
val label: String
)
/**
* Предустановленные варианты цвета/градиента для background blur в профиле.
* Референс: сетка цветных кружков как в desktop-версии.
*/
object BackgroundBlurPresets {
/** Вариант "по умолчанию" — используется blur аватарки без цветного overlay */
val avatarDefault = BackgroundBlurOption(
id = "avatar",
colors = emptyList(),
label = "Avatar"
)
/** Сплошные цвета */
private val solidColors = listOf(
BackgroundBlurOption("solid_blue", listOf(Color(0xFF2979FF)), "Blue"),
BackgroundBlurOption("solid_green", listOf(Color(0xFF4CAF50)), "Green"),
BackgroundBlurOption("solid_orange", listOf(Color(0xFFFF9800)), "Orange"),
BackgroundBlurOption("solid_red", listOf(Color(0xFFE53935)), "Red"),
BackgroundBlurOption("solid_purple", listOf(Color(0xFF7C4DFF)), "Purple"),
BackgroundBlurOption("solid_teal", listOf(Color(0xFF009688)), "Teal"),
BackgroundBlurOption("solid_pink", listOf(Color(0xFFE91E63)), "Pink"),
BackgroundBlurOption("solid_grey", listOf(Color(0xFF78909C)), "Grey"),
)
/** Градиенты (по 2 цвета, как на референсе) */
private val gradients = listOf(
BackgroundBlurOption("grad_blue_cyan", listOf(Color(0xFF2979FF), Color(0xFF00BCD4)), "Blue|Cyan"),
BackgroundBlurOption("grad_green_lime", listOf(Color(0xFF4CAF50), Color(0xFFCDDC39)), "Green|Lime"),
BackgroundBlurOption("grad_orange_yellow", listOf(Color(0xFFFF9800), Color(0xFFFFEB3B)), "Orange|Yellow"),
BackgroundBlurOption("grad_red_pink", listOf(Color(0xFFE53935), Color(0xFFFF4081)), "Red|Pink"),
BackgroundBlurOption("grad_purple_blue", listOf(Color(0xFF7C4DFF), Color(0xFF536DFE)), "Purple|Blue"),
BackgroundBlurOption("grad_teal_green", listOf(Color(0xFF009688), Color(0xFF69F0AE)), "Teal|Green"),
BackgroundBlurOption("grad_pink_magenta", listOf(Color(0xFFE91E63), Color(0xFFCE93D8)), "Pink|Magenta"),
BackgroundBlurOption("grad_mono", listOf(Color(0xFF546E7A), Color(0xFFB0BEC5)), "Mono"),
)
/** Все варианты в порядке отображения: сначала сплошные, потом градиенты */
val all: List<BackgroundBlurOption> = solidColors + gradients
/** Все варианты включая "Avatar" (default) */
val allWithDefault: List<BackgroundBlurOption> = listOf(avatarDefault) + all
/**
* Найти вариант по id. Возвращает [avatarDefault] если не найден.
*/
fun findById(id: String): BackgroundBlurOption {
return allWithDefault.find { it.id == id } ?: avatarDefault
}
/**
* Получить список цветов для overlay по id.
* Возвращает null если id == "avatar" (значит используется blur аватарки без overlay).
*/
fun getOverlayColors(id: String): List<Color>? {
val option = findById(id)
return if (option.colors.isEmpty()) null else option.colors
}
}

View File

@@ -0,0 +1,573 @@
package com.rosetta.messenger.ui.settings
import android.graphics.Bitmap
import androidx.activity.compose.BackHandler
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import compose.icons.TablerIcons
import compose.icons.tablericons.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.utils.AvatarFileManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import compose.icons.tablericons.Sun
import compose.icons.tablericons.Moon
/**
* Экран кастомизации внешнего вида.
* Позволяет выбрать цвет/градиент для background blur в профиле.
* Preview повторяет avatar block из профиля — реальный blur + аватарка.
*/
@Composable
fun AppearanceScreen(
isDarkTheme: Boolean,
currentBlurColorId: String,
onBack: () -> Unit,
onBlurColorChange: (String) -> Unit,
onToggleTheme: () -> Unit = {},
accountPublicKey: String = "",
accountName: String = "",
avatarRepository: AvatarRepository? = null
) {
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF)
val textColor = if (isDarkTheme) Color.White else Color.Black
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
var selectedId by remember { mutableStateOf(currentBlurColorId) }
BackHandler { onBack() }
Column(
modifier = Modifier
.fillMaxSize()
.background(backgroundColor)
) {
// ═══════════════════════════════════════════════════════════
// CONTENT
// ═══════════════════════════════════════════════════════════
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
) {
// ═══════════════════════════════════════════════════════
// LIVE PREVIEW — реальный avatar block из профиля
// Покрывает status bar, кнопки overlay поверх
// ═══════════════════════════════════════════════════════
Box {
ProfileBlurPreview(
selectedId = selectedId,
isDarkTheme = isDarkTheme,
publicKey = accountPublicKey,
displayName = accountName,
avatarRepository = avatarRepository
)
// Overlay: кнопка назад (слева) и смена темы (справа)
Row(
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
.padding(horizontal = 4.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = onBack) {
Icon(
imageVector = TablerIcons.ArrowLeft,
contentDescription = "Back",
tint = Color.White
)
}
IconButton(onClick = onToggleTheme) {
Icon(
imageVector = if (isDarkTheme) TablerIcons.Sun else TablerIcons.Moon,
contentDescription = "Toggle theme",
tint = Color.White
)
}
}
}
Spacer(modifier = Modifier.height(24.dp))
// ═══════════════════════════════════════════════════════
// SECTION TITLE
// ═══════════════════════════════════════════════════════
Text(
text = "BACKGROUND COLOR",
fontSize = 13.sp,
fontWeight = FontWeight.Medium,
color = secondaryTextColor,
letterSpacing = 0.5.sp,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
// ═══════════════════════════════════════════════════════
// COLOR GRID
// ═══════════════════════════════════════════════════════
ColorSelectionGrid(
selectedId = selectedId,
isDarkTheme = isDarkTheme,
onSelect = { id ->
selectedId = id
onBlurColorChange(id)
}
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Choose a color or gradient overlay for the blurred header background in your profile.",
fontSize = 13.sp,
color = secondaryTextColor,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
lineHeight = 18.sp
)
Spacer(modifier = Modifier.height(32.dp))
}
}
}
// ═══════════════════════════════════════════════════════════════════════
// 🎨 LIVE PREVIEW — повторяет avatar block из ProfileScreen
// Реальный blur аватарки + overlay + круглая аватарка + имя
// ═══════════════════════════════════════════════════════════════════════
@Composable
private fun ProfileBlurPreview(
selectedId: String,
isDarkTheme: Boolean,
publicKey: String,
displayName: String,
avatarRepository: AvatarRepository?
) {
val option = BackgroundBlurPresets.findById(selectedId)
val overlayColors = BackgroundBlurPresets.getOverlayColors(selectedId)
val avatarColors = getAvatarColor(publicKey, isDarkTheme)
// Загрузка аватарки
val avatars by avatarRepository?.getAvatars(publicKey, allDecode = false)?.collectAsState()
?: remember { mutableStateOf(emptyList()) }
val avatarKey = remember(avatars) { avatars.firstOrNull()?.timestamp ?: 0L }
var avatarBitmap by remember { mutableStateOf<Bitmap?>(null) }
var blurredBitmap by remember { mutableStateOf<Bitmap?>(null) }
LaunchedEffect(avatarKey) {
val current = avatars
if (current.isNotEmpty()) {
val decoded = withContext(Dispatchers.IO) {
AvatarFileManager.base64ToBitmap(current.first().base64Data)
}
if (decoded != null) {
avatarBitmap = decoded
// Blur для фонового изображения
blurredBitmap = withContext(Dispatchers.Default) {
val scaled = Bitmap.createScaledBitmap(
decoded,
decoded.width / 4,
decoded.height / 4,
true
)
var result = scaled
repeat(3) {
result = fastBlur(result, 6)
}
result
}
}
} else {
avatarBitmap = null
blurredBitmap = null
}
}
// Анимированный label
val labelText = if (option.colors.isEmpty()) "Avatar Blur" else option.label
// Preview — повторяет profile header, покрывает status bar
val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
Box(
modifier = Modifier
.fillMaxWidth()
.height(280.dp + statusBarHeight)
) {
// ═══════════════════════════════════════════════════
// LAYER 1: Blurred avatar background (как в профиле)
// ═══════════════════════════════════════════════════
if (blurredBitmap != null) {
Image(
bitmap = blurredBitmap!!.asImageBitmap(),
contentDescription = null,
modifier = Modifier
.fillMaxSize()
.graphicsLayer { alpha = 0.35f },
contentScale = ContentScale.Crop
)
}
// ═══════════════════════════════════════════════════
// LAYER 2: Color/gradient overlay (или fallback)
// ═══════════════════════════════════════════════════
if (overlayColors != null && overlayColors.isNotEmpty()) {
val overlayMod = if (overlayColors.size == 1) {
Modifier
.fillMaxSize()
.background(overlayColors[0].copy(alpha = if (blurredBitmap != null) 0.55f else 0.85f))
} else {
Modifier
.fillMaxSize()
.background(
Brush.linearGradient(
colors = overlayColors.map {
it.copy(alpha = if (blurredBitmap != null) 0.55f else 0.85f)
}
)
)
}
Box(modifier = overlayMod)
} else if (blurredBitmap != null) {
// Стандартный затемняющий overlay (как в профиле)
Box(
modifier = Modifier
.fillMaxSize()
.background(avatarColors.backgroundColor.copy(alpha = 0.3f))
)
} else {
// Нет аватарки и нет overlay — fallback цвет
Box(
modifier = Modifier
.fillMaxSize()
.background(
if (isDarkTheme) Color(0xFF2A2A2E) else Color(0xFFD8D8DC)
)
)
}
// ═══════════════════════════════════════════════════
// LAYER 3: Тонкий нижний градиент-затемнение
// ═══════════════════════════════════════════════════
Box(
modifier = Modifier
.fillMaxWidth()
.height(80.dp)
.align(Alignment.BottomCenter)
.background(
Brush.verticalGradient(
colors = listOf(
Color.Transparent,
Color.Black.copy(alpha = 0.35f)
)
)
)
)
// ═══════════════════════════════════════════════════
// LAYER 4: Avatar circle + Name + subtitle
// Повторяет layout из CollapsingProfileHeader
// ═══════════════════════════════════════════════════
Column(
modifier = Modifier
.fillMaxSize()
.padding(top = statusBarHeight)
.padding(bottom = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
// Круглая аватарка с shadow — 120dp как в ProfileScreen
Box(
modifier = Modifier
.size(120.dp)
.shadow(12.dp, CircleShape)
.clip(CircleShape)
.background(avatarColors.backgroundColor),
contentAlignment = Alignment.Center
) {
if (avatarBitmap != null) {
Image(
bitmap = avatarBitmap!!.asImageBitmap(),
contentDescription = "Avatar",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
} else {
// Placeholder с инициалами (40sp как в ProfileScreen)
Text(
text = getInitials(displayName.ifBlank { publicKey.take(6) }),
fontSize = 40.sp,
fontWeight = FontWeight.Bold,
color = avatarColors.textColor
)
}
}
Spacer(modifier = Modifier.height(14.dp))
// Имя пользователя
Text(
text = displayName.ifBlank { publicKey.take(10) + "..." },
fontSize = 24.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White,
textAlign = TextAlign.Center,
maxLines = 1
)
Spacer(modifier = Modifier.height(2.dp))
// Label текущего пресета — с иконкой стрелки для градиентов
val labelParts = labelText.split("|")
if (labelParts.size == 2) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Text(
text = labelParts[0],
fontSize = 14.sp,
color = Color.White.copy(alpha = 0.7f)
)
Icon(
imageVector = TablerIcons.ArrowNarrowRight,
contentDescription = null,
tint = Color.White.copy(alpha = 0.5f),
modifier = Modifier
.padding(horizontal = 4.dp)
.size(16.dp)
)
Text(
text = labelParts[1],
fontSize = 14.sp,
color = Color.White.copy(alpha = 0.7f)
)
}
} else {
Text(
text = labelText,
fontSize = 14.sp,
color = Color.White.copy(alpha = 0.7f),
textAlign = TextAlign.Center
)
}
}
}
}
// ═══════════════════════════════════════════════════════════════════
// 🎨 COLOR GRID — сетка выбора цветов (8 в ряду)
// ═══════════════════════════════════════════════════════════════════
@Composable
private fun ColorSelectionGrid(
selectedId: String,
isDarkTheme: Boolean,
onSelect: (String) -> Unit
) {
val allOptions = BackgroundBlurPresets.allWithDefault
val columns = 8
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp)
) {
allOptions.chunked(columns).forEach { rowItems ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 6.dp),
horizontalArrangement = Arrangement.SpaceEvenly
) {
rowItems.forEach { option ->
ColorCircleItem(
option = option,
isSelected = option.id == selectedId,
isDarkTheme = isDarkTheme,
onClick = { onSelect(option.id) }
)
}
repeat(columns - rowItems.size) {
Spacer(modifier = Modifier.size(40.dp))
}
}
}
}
}
@Composable
private fun ColorCircleItem(
option: BackgroundBlurOption,
isSelected: Boolean,
isDarkTheme: Boolean,
onClick: () -> Unit
) {
val scale by animateFloatAsState(
targetValue = if (isSelected) 1.15f else 1.0f,
animationSpec = tween(200),
label = "scale"
)
val borderColor by animateColorAsState(
targetValue = if (isSelected) {
if (isDarkTheme) Color.White else Color(0xFF222222)
} else {
Color.Transparent
},
animationSpec = tween(200),
label = "border"
)
Box(
modifier = Modifier
.size(40.dp)
.scale(scale)
.clip(CircleShape)
.border(
width = if (isSelected) 2.5.dp else 0.5.dp,
color = if (isSelected) borderColor else Color.White.copy(alpha = 0.12f),
shape = CircleShape
)
.clickable(onClick = onClick),
contentAlignment = Alignment.Center
) {
when {
option.id == "avatar" -> {
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.linearGradient(
colors = listOf(Color(0xFF3A3A3C), Color(0xFF8E8E93))
)
),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = TablerIcons.CircleOff,
contentDescription = "Default",
tint = Color.White.copy(alpha = 0.9f),
modifier = Modifier.size(18.dp)
)
}
}
option.colors.size == 1 -> {
Box(
modifier = Modifier
.fillMaxSize()
.background(option.colors[0])
)
}
option.colors.size >= 2 -> {
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.linearGradient(colors = option.colors)
)
)
}
}
// Галочка с затемнённым фоном
if (isSelected) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.25f)),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = TablerIcons.Check,
contentDescription = "Selected",
tint = Color.White,
modifier = Modifier.size(18.dp)
)
}
}
}
}
/**
* Быстрый box blur (для preview, идентично BlurredAvatarBackground)
*/
private fun fastBlur(source: Bitmap, radius: Int): Bitmap {
if (radius < 1) return source
val w = source.width
val h = source.height
val bitmap = source.copy(source.config, true)
val pixels = IntArray(w * h)
bitmap.getPixels(pixels, 0, w, 0, 0, w, h)
for (y in 0 until h) blurRow(pixels, y, w, radius)
for (x in 0 until w) blurColumn(pixels, x, w, h, radius)
bitmap.setPixels(pixels, 0, w, 0, 0, w, h)
return bitmap
}
private fun blurRow(pixels: IntArray, y: Int, w: Int, radius: Int) {
var sR = 0; var sG = 0; var sB = 0; var sA = 0
val dv = radius * 2 + 1; val off = y * w
for (i in -radius..radius) {
val x = i.coerceIn(0, w - 1); val p = pixels[off + x]
sA += (p shr 24) and 0xff; sR += (p shr 16) and 0xff
sG += (p shr 8) and 0xff; sB += p and 0xff
}
for (x in 0 until w) {
pixels[off + x] = ((sA / dv) shl 24) or ((sR / dv) shl 16) or ((sG / dv) shl 8) or (sB / dv)
val xL = (x - radius).coerceIn(0, w - 1); val xR = (x + radius + 1).coerceIn(0, w - 1)
val lp = pixels[off + xL]; val rp = pixels[off + xR]
sA += ((rp shr 24) and 0xff) - ((lp shr 24) and 0xff)
sR += ((rp shr 16) and 0xff) - ((lp shr 16) and 0xff)
sG += ((rp shr 8) and 0xff) - ((lp shr 8) and 0xff)
sB += (rp and 0xff) - (lp and 0xff)
}
}
private fun blurColumn(pixels: IntArray, x: Int, w: Int, h: Int, radius: Int) {
var sR = 0; var sG = 0; var sB = 0; var sA = 0
val dv = radius * 2 + 1
for (i in -radius..radius) {
val y = i.coerceIn(0, h - 1); val p = pixels[y * w + x]
sA += (p shr 24) and 0xff; sR += (p shr 16) and 0xff
sG += (p shr 8) and 0xff; sB += p and 0xff
}
for (y in 0 until h) {
val off = y * w + x
pixels[off] = ((sA / dv) shl 24) or ((sR / dv) shl 16) or ((sG / dv) shl 8) or (sB / dv)
val yT = (y - radius).coerceIn(0, h - 1); val yB = (y + radius + 1).coerceIn(0, h - 1)
val tp = pixels[yT * w + x]; val bp = pixels[yB * w + x]
sA += ((bp shr 24) and 0xff) - ((tp shr 24) and 0xff)
sR += ((bp shr 16) and 0xff) - ((tp shr 16) and 0xff)
sG += ((bp shr 8) and 0xff) - ((tp shr 8) and 0xff)
sB += (bp and 0xff) - (tp and 0xff)
}
}

View File

@@ -110,7 +110,8 @@ fun OtherProfileScreen(
user: SearchUser,
isDarkTheme: Boolean,
onBack: () -> Unit,
avatarRepository: AvatarRepository? = null
avatarRepository: AvatarRepository? = null,
backgroundBlurColorId: String = "avatar"
) {
var isBlocked by remember { mutableStateOf(false) }
var showAvatarMenu by remember { mutableStateOf(false) }
@@ -457,7 +458,8 @@ fun OtherProfileScreen(
} catch (e: Exception) {
}
}
}
},
backgroundBlurColorId = backgroundBlurColorId
)
}
}
@@ -483,7 +485,8 @@ private fun CollapsingOtherProfileHeader(
isBlocked: Boolean,
onBlockToggle: () -> Unit,
avatarRepository: AvatarRepository? = null,
onClearChat: () -> Unit
onClearChat: () -> Unit,
backgroundBlurColorId: String = "avatar"
) {
val density = LocalDensity.current
@@ -521,7 +524,8 @@ private fun CollapsingOtherProfileHeader(
avatarRepository = avatarRepository,
fallbackColor = avatarColors.backgroundColor,
blurRadius = 25f,
alpha = 0.3f
alpha = 0.3f,
overlayColors = com.rosetta.messenger.ui.settings.BackgroundBlurPresets.getOverlayColors(backgroundBlurColorId)
)
// ═══════════════════════════════════════════════════════════

View File

@@ -189,13 +189,15 @@ fun ProfileScreen(
onSaveProfile: (name: String, username: String) -> Unit,
onLogout: () -> Unit,
onNavigateToTheme: () -> Unit = {},
onNavigateToAppearance: () -> Unit = {},
onNavigateToSafety: () -> Unit = {},
onNavigateToLogs: () -> Unit = {},
onNavigateToCrashLogs: () -> Unit = {},
onNavigateToBiometric: () -> Unit = {},
viewModel: ProfileViewModel = androidx.lifecycle.viewmodel.compose.viewModel(),
avatarRepository: AvatarRepository? = null,
dialogDao: com.rosetta.messenger.database.DialogDao? = null
dialogDao: com.rosetta.messenger.database.DialogDao? = null,
backgroundBlurColorId: String = "avatar"
) {
val context = LocalContext.current
val activity = context as? FragmentActivity
@@ -633,6 +635,14 @@ fun ProfileScreen(
showDivider = true
)
TelegramSettingsItem(
icon = TablerIcons.Brush,
title = "Appearance",
onClick = onNavigateToAppearance,
isDarkTheme = isDarkTheme,
showDivider = true
)
TelegramSettingsItem(
icon = TablerIcons.Lock,
title = "Safety",
@@ -716,7 +726,8 @@ fun ProfileScreen(
}
},
hasAvatar = hasAvatar,
avatarRepository = avatarRepository
avatarRepository = avatarRepository,
backgroundBlurColorId = backgroundBlurColorId
)
}
@@ -757,7 +768,8 @@ private fun CollapsingProfileHeader(
onSetPhotoClick: () -> Unit,
onDeletePhotoClick: () -> Unit,
hasAvatar: Boolean,
avatarRepository: AvatarRepository?
avatarRepository: AvatarRepository?,
backgroundBlurColorId: String = "avatar"
) {
@Suppress("UNUSED_VARIABLE")
val density = LocalDensity.current
@@ -808,7 +820,8 @@ private fun CollapsingProfileHeader(
avatarRepository = avatarRepository,
fallbackColor = avatarColors.backgroundColor,
blurRadius = 25f,
alpha = 0.3f
alpha = 0.3f,
overlayColors = BackgroundBlurPresets.getOverlayColors(backgroundBlurColorId)
)
// ═══════════════════════════════════════════════════════════