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 showLogsScreen by remember { mutableStateOf(false) }
|
||||||
var showCrashLogsScreen by remember { mutableStateOf(false) }
|
var showCrashLogsScreen by remember { mutableStateOf(false) }
|
||||||
var showBiometricScreen by remember { mutableStateOf(false) }
|
var showBiometricScreen by remember { mutableStateOf(false) }
|
||||||
|
var showAppearanceScreen by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
// ProfileViewModel для логов
|
// ProfileViewModel для логов
|
||||||
val profileViewModel: com.rosetta.messenger.ui.settings.ProfileViewModel = androidx.lifecycle.viewmodel.compose.viewModel()
|
val profileViewModel: com.rosetta.messenger.ui.settings.ProfileViewModel = androidx.lifecycle.viewmodel.compose.viewModel()
|
||||||
val profileState by profileViewModel.state.collectAsState()
|
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 для работы с аватарами
|
// AvatarRepository для работы с аватарами
|
||||||
val avatarRepository = remember(accountPublicKey) {
|
val avatarRepository = remember(accountPublicKey) {
|
||||||
if (accountPublicKey.isNotBlank() && accountPublicKey != "04c266b98ae5") {
|
if (accountPublicKey.isNotBlank() && accountPublicKey != "04c266b98ae5") {
|
||||||
@@ -608,10 +613,61 @@ fun MainScreen(
|
|||||||
// TODO: Show new chat screen
|
// TODO: Show new chat screen
|
||||||
},
|
},
|
||||||
onUserSelect = { selectedChatUser -> selectedUser = selectedChatUser },
|
onUserSelect = { selectedChatUser -> selectedUser = selectedChatUser },
|
||||||
|
backgroundBlurColorId = backgroundBlurColorId,
|
||||||
avatarRepository = avatarRepository,
|
avatarRepository = avatarRepository,
|
||||||
onLogout = onLogout
|
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
|
// Other screens with swipe back
|
||||||
SwipeBackContainer(
|
SwipeBackContainer(
|
||||||
isVisible = showBackupScreen,
|
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(
|
SwipeBackContainer(
|
||||||
isVisible = showUpdatesScreen,
|
isVisible = showUpdatesScreen,
|
||||||
onBack = { showUpdatesScreen = false },
|
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(
|
SwipeBackContainer(
|
||||||
isVisible = showLogsScreen,
|
isVisible = showLogsScreen,
|
||||||
onBack = {
|
onBack = {
|
||||||
@@ -867,7 +898,8 @@ fun MainScreen(
|
|||||||
showOtherProfileScreen = false
|
showOtherProfileScreen = false
|
||||||
selectedOtherUser = null
|
selectedOtherUser = null
|
||||||
},
|
},
|
||||||
avatarRepository = avatarRepository
|
avatarRepository = avatarRepository,
|
||||||
|
backgroundBlurColorId = backgroundBlurColorId
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ class PreferencesManager(private val context: Context) {
|
|||||||
|
|
||||||
// Language
|
// Language
|
||||||
val APP_LANGUAGE = stringPreferencesKey("app_language") // "en", "ru", etc.
|
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) {
|
suspend fun setAppLanguage(value: String) {
|
||||||
context.dataStore.edit { preferences -> preferences[APP_LANGUAGE] = value }
|
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.AvatarImage
|
||||||
import com.rosetta.messenger.ui.components.BlurredAvatarBackground
|
import com.rosetta.messenger.ui.components.BlurredAvatarBackground
|
||||||
import com.rosetta.messenger.ui.components.VerifiedBadge
|
import com.rosetta.messenger.ui.components.VerifiedBadge
|
||||||
|
import com.rosetta.messenger.ui.settings.BackgroundBlurPresets
|
||||||
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.*
|
import java.util.*
|
||||||
@@ -167,6 +168,7 @@ fun ChatsListScreen(
|
|||||||
onSearchClick: () -> Unit,
|
onSearchClick: () -> Unit,
|
||||||
onNewChat: () -> Unit,
|
onNewChat: () -> Unit,
|
||||||
onUserSelect: (com.rosetta.messenger.network.SearchUser) -> Unit = {},
|
onUserSelect: (com.rosetta.messenger.network.SearchUser) -> Unit = {},
|
||||||
|
backgroundBlurColorId: String = "avatar",
|
||||||
chatsViewModel: ChatsListViewModel = androidx.lifecycle.viewmodel.compose.viewModel(),
|
chatsViewModel: ChatsListViewModel = androidx.lifecycle.viewmodel.compose.viewModel(),
|
||||||
avatarRepository: com.rosetta.messenger.repository.AvatarRepository? = null,
|
avatarRepository: com.rosetta.messenger.repository.AvatarRepository? = null,
|
||||||
onLogout: () -> Unit
|
onLogout: () -> Unit
|
||||||
@@ -453,7 +455,8 @@ android.util.Log.d("ChatsListScreen", "✅ Total LaunchedEffect: ${System.curren
|
|||||||
avatarRepository = avatarRepository,
|
avatarRepository = avatarRepository,
|
||||||
fallbackColor = headerColor,
|
fallbackColor = headerColor,
|
||||||
blurRadius = 40f,
|
blurRadius = 40f,
|
||||||
alpha = 0.6f
|
alpha = 0.6f,
|
||||||
|
overlayColors = BackgroundBlurPresets.getOverlayColors(backgroundBlurColorId)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Content поверх фона
|
// Content поверх фона
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.BoxScope
|
|||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.asImageBitmap
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
@@ -28,6 +29,8 @@ import kotlinx.coroutines.withContext
|
|||||||
* @param fallbackColor Цвет фона если нет аватарки
|
* @param fallbackColor Цвет фона если нет аватарки
|
||||||
* @param blurRadius Радиус размытия (в пикселях) - применяется при обработке
|
* @param blurRadius Радиус размытия (в пикселях) - применяется при обработке
|
||||||
* @param alpha Прозрачность (0.0 - 1.0)
|
* @param alpha Прозрачность (0.0 - 1.0)
|
||||||
|
* @param overlayColors Опциональные цвета overlay поверх blur. Если null — стандартное поведение.
|
||||||
|
* Если 1 цвет — сплошной overlay, если 2+ — градиент.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun BoxScope.BlurredAvatarBackground(
|
fun BoxScope.BlurredAvatarBackground(
|
||||||
@@ -35,7 +38,8 @@ fun BoxScope.BlurredAvatarBackground(
|
|||||||
avatarRepository: AvatarRepository?,
|
avatarRepository: AvatarRepository?,
|
||||||
fallbackColor: Color,
|
fallbackColor: Color,
|
||||||
blurRadius: Float = 25f,
|
blurRadius: Float = 25f,
|
||||||
alpha: Float = 0.3f
|
alpha: Float = 0.3f,
|
||||||
|
overlayColors: List<Color>? = null
|
||||||
) {
|
) {
|
||||||
// Получаем аватары из репозитория
|
// Получаем аватары из репозитория
|
||||||
val avatars by avatarRepository?.getAvatars(publicKey, allDecode = false)?.collectAsState()
|
val avatars by avatarRepository?.getAvatars(publicKey, allDecode = false)?.collectAsState()
|
||||||
@@ -91,19 +95,55 @@ fun BoxScope.BlurredAvatarBackground(
|
|||||||
contentScale = ContentScale.Crop
|
contentScale = ContentScale.Crop
|
||||||
)
|
)
|
||||||
|
|
||||||
// Дополнительный overlay для затемнения
|
// Дополнительный overlay — кастомный или стандартный
|
||||||
Box(
|
if (overlayColors != null && overlayColors.isNotEmpty()) {
|
||||||
modifier = Modifier
|
// Кастомный цветной overlay
|
||||||
.fillMaxSize()
|
val overlayModifier = if (overlayColors.size == 1) {
|
||||||
.background(fallbackColor.copy(alpha = 0.3f))
|
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 {
|
} else {
|
||||||
// Fallback: цветной фон
|
// Fallback: когда нет аватарки
|
||||||
Box(
|
if (overlayColors != null && overlayColors.isNotEmpty()) {
|
||||||
modifier = Modifier
|
// Кастомный фон без blur
|
||||||
.fillMaxSize()
|
val bgModifier = if (overlayColors.size == 1) {
|
||||||
.background(fallbackColor)
|
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,
|
user: SearchUser,
|
||||||
isDarkTheme: Boolean,
|
isDarkTheme: Boolean,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
avatarRepository: AvatarRepository? = null
|
avatarRepository: AvatarRepository? = null,
|
||||||
|
backgroundBlurColorId: String = "avatar"
|
||||||
) {
|
) {
|
||||||
var isBlocked by remember { mutableStateOf(false) }
|
var isBlocked by remember { mutableStateOf(false) }
|
||||||
var showAvatarMenu by remember { mutableStateOf(false) }
|
var showAvatarMenu by remember { mutableStateOf(false) }
|
||||||
@@ -457,7 +458,8 @@ fun OtherProfileScreen(
|
|||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
backgroundBlurColorId = backgroundBlurColorId
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -483,7 +485,8 @@ private fun CollapsingOtherProfileHeader(
|
|||||||
isBlocked: Boolean,
|
isBlocked: Boolean,
|
||||||
onBlockToggle: () -> Unit,
|
onBlockToggle: () -> Unit,
|
||||||
avatarRepository: AvatarRepository? = null,
|
avatarRepository: AvatarRepository? = null,
|
||||||
onClearChat: () -> Unit
|
onClearChat: () -> Unit,
|
||||||
|
backgroundBlurColorId: String = "avatar"
|
||||||
) {
|
) {
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
|
|
||||||
@@ -521,7 +524,8 @@ private fun CollapsingOtherProfileHeader(
|
|||||||
avatarRepository = avatarRepository,
|
avatarRepository = avatarRepository,
|
||||||
fallbackColor = avatarColors.backgroundColor,
|
fallbackColor = avatarColors.backgroundColor,
|
||||||
blurRadius = 25f,
|
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,
|
onSaveProfile: (name: String, username: String) -> Unit,
|
||||||
onLogout: () -> Unit,
|
onLogout: () -> Unit,
|
||||||
onNavigateToTheme: () -> Unit = {},
|
onNavigateToTheme: () -> Unit = {},
|
||||||
|
onNavigateToAppearance: () -> Unit = {},
|
||||||
onNavigateToSafety: () -> Unit = {},
|
onNavigateToSafety: () -> Unit = {},
|
||||||
onNavigateToLogs: () -> Unit = {},
|
onNavigateToLogs: () -> Unit = {},
|
||||||
onNavigateToCrashLogs: () -> Unit = {},
|
onNavigateToCrashLogs: () -> Unit = {},
|
||||||
onNavigateToBiometric: () -> Unit = {},
|
onNavigateToBiometric: () -> Unit = {},
|
||||||
viewModel: ProfileViewModel = androidx.lifecycle.viewmodel.compose.viewModel(),
|
viewModel: ProfileViewModel = androidx.lifecycle.viewmodel.compose.viewModel(),
|
||||||
avatarRepository: AvatarRepository? = null,
|
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 context = LocalContext.current
|
||||||
val activity = context as? FragmentActivity
|
val activity = context as? FragmentActivity
|
||||||
@@ -633,6 +635,14 @@ fun ProfileScreen(
|
|||||||
showDivider = true
|
showDivider = true
|
||||||
)
|
)
|
||||||
|
|
||||||
|
TelegramSettingsItem(
|
||||||
|
icon = TablerIcons.Brush,
|
||||||
|
title = "Appearance",
|
||||||
|
onClick = onNavigateToAppearance,
|
||||||
|
isDarkTheme = isDarkTheme,
|
||||||
|
showDivider = true
|
||||||
|
)
|
||||||
|
|
||||||
TelegramSettingsItem(
|
TelegramSettingsItem(
|
||||||
icon = TablerIcons.Lock,
|
icon = TablerIcons.Lock,
|
||||||
title = "Safety",
|
title = "Safety",
|
||||||
@@ -716,7 +726,8 @@ fun ProfileScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
hasAvatar = hasAvatar,
|
hasAvatar = hasAvatar,
|
||||||
avatarRepository = avatarRepository
|
avatarRepository = avatarRepository,
|
||||||
|
backgroundBlurColorId = backgroundBlurColorId
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -757,7 +768,8 @@ private fun CollapsingProfileHeader(
|
|||||||
onSetPhotoClick: () -> Unit,
|
onSetPhotoClick: () -> Unit,
|
||||||
onDeletePhotoClick: () -> Unit,
|
onDeletePhotoClick: () -> Unit,
|
||||||
hasAvatar: Boolean,
|
hasAvatar: Boolean,
|
||||||
avatarRepository: AvatarRepository?
|
avatarRepository: AvatarRepository?,
|
||||||
|
backgroundBlurColorId: String = "avatar"
|
||||||
) {
|
) {
|
||||||
@Suppress("UNUSED_VARIABLE")
|
@Suppress("UNUSED_VARIABLE")
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
@@ -808,7 +820,8 @@ private fun CollapsingProfileHeader(
|
|||||||
avatarRepository = avatarRepository,
|
avatarRepository = avatarRepository,
|
||||||
fallbackColor = avatarColors.backgroundColor,
|
fallbackColor = avatarColors.backgroundColor,
|
||||||
blurRadius = 25f,
|
blurRadius = 25f,
|
||||||
alpha = 0.3f
|
alpha = 0.3f,
|
||||||
|
overlayColors = BackgroundBlurPresets.getOverlayColors(backgroundBlurColorId)
|
||||||
)
|
)
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|||||||
Reference in New Issue
Block a user