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:
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 поверх фона
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
Reference in New Issue
Block a user