feat: Add BlurredAvatarBackground component and integrate it into ChatsListScreen and ProfileScreen

This commit is contained in:
k1ngsterr1
2026-01-24 20:18:27 +05:00
parent 23e1d72ac0
commit dc548a3c7a
8 changed files with 420 additions and 29 deletions

View File

@@ -38,6 +38,7 @@ import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.network.ProtocolState
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.onboarding.PrimaryBlue
import java.text.SimpleDateFormat
import java.util.*
@@ -385,12 +386,25 @@ fun ChatsListScreen(
)
val headerColor = avatarColors.backgroundColor
// Header с размытым фоном аватарки
Box(
modifier =
Modifier.fillMaxWidth()
.background(
color = headerColor
)
modifier = Modifier.fillMaxWidth()
) {
// ═══════════════════════════════════════════════════════════
// 🎨 BLURRED AVATAR BACKGROUND (на всю область header)
// ═══════════════════════════════════════════════════════════
BlurredAvatarBackground(
publicKey = accountPublicKey,
avatarRepository = avatarRepository,
fallbackColor = headerColor,
blurRadius = 40f,
alpha = 0.6f
)
// Content поверх фона
Column(
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
.padding(
top = 16.dp,
@@ -398,8 +412,7 @@ fun ChatsListScreen(
end = 20.dp,
bottom = 20.dp
)
) {
Column {
) {
// Avatar - используем AvatarImage
Box(
modifier =

View File

@@ -0,0 +1,219 @@
package com.rosetta.messenger.ui.components
import android.graphics.Bitmap
import android.os.Build
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
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 com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.utils.AvatarFileManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* Компонент для отображения размытого фона аватарки
* Используется в профиле и сайдбаре
* ВАЖНО: Должен вызываться внутри BoxScope чтобы matchParentSize() работал
*
* @param publicKey Публичный ключ пользователя
* @param avatarRepository Репозиторий аватаров
* @param fallbackColor Цвет фона если нет аватарки
* @param blurRadius Радиус размытия (в пикселях) - применяется при обработке
* @param alpha Прозрачность (0.0 - 1.0)
*/
@Composable
fun BoxScope.BlurredAvatarBackground(
publicKey: String,
avatarRepository: AvatarRepository?,
fallbackColor: Color,
blurRadius: Float = 25f,
alpha: Float = 0.3f
) {
// Получаем аватары из репозитория
val avatars by avatarRepository?.getAvatars(publicKey, allDecode = false)?.collectAsState()
?: remember { mutableStateOf(emptyList()) }
// Состояние для bitmap и размытого bitmap
var originalBitmap by remember(avatars) { mutableStateOf<Bitmap?>(null) }
var blurredBitmap by remember(avatars) { mutableStateOf<Bitmap?>(null) }
// Декодируем и размываем аватар
LaunchedEffect(avatars) {
if (avatars.isNotEmpty()) {
originalBitmap = withContext(Dispatchers.IO) {
AvatarFileManager.base64ToBitmap(avatars.first().base64Data)
}
// Размываем bitmap (уменьшаем для производительности, затем применяем blur)
originalBitmap?.let { bitmap ->
blurredBitmap = withContext(Dispatchers.Default) {
// Уменьшаем размер для быстрого blur
val scaledBitmap = Bitmap.createScaledBitmap(
bitmap,
bitmap.width / 4,
bitmap.height / 4,
true
)
// Применяем blur несколько раз для более гладкого эффекта
var result = scaledBitmap
repeat(3) {
result = fastBlur(result, (blurRadius / 4).toInt().coerceAtLeast(1))
}
result
}
}
} else {
originalBitmap = null
blurredBitmap = null
}
}
// Используем matchParentSize() чтобы занимать только размер родителя
Box(modifier = Modifier.matchParentSize()) {
if (blurredBitmap != null) {
// Показываем размытое изображение
Image(
bitmap = blurredBitmap!!.asImageBitmap(),
contentDescription = null,
modifier = Modifier
.fillMaxSize()
.graphicsLayer {
this.alpha = alpha
},
contentScale = ContentScale.Crop
)
// Дополнительный overlay для затемнения
Box(
modifier = Modifier
.fillMaxSize()
.background(fallbackColor.copy(alpha = 0.3f))
)
} else {
// Fallback: цветной фон
Box(
modifier = Modifier
.fillMaxSize()
.background(fallbackColor)
)
}
}
}
/**
* Быстрое размытие по Гауссу (Box Blur - упрощенная версия)
* Основано на Stack Blur Algorithm от Mario Klingemann
*/
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, h, 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, h: Int, radius: Int) {
var sumR = 0
var sumG = 0
var sumB = 0
var sumA = 0
val dv = radius * 2 + 1
val offset = y * w
// Инициализация суммы
for (i in -radius..radius) {
val x = i.coerceIn(0, w - 1)
val pixel = pixels[offset + x]
sumA += (pixel shr 24) and 0xff
sumR += (pixel shr 16) and 0xff
sumG += (pixel shr 8) and 0xff
sumB += pixel and 0xff
}
// Применяем blur
for (x in 0 until w) {
pixels[offset + x] = ((sumA / dv) shl 24) or
((sumR / dv) shl 16) or
((sumG / dv) shl 8) or
(sumB / dv)
// Обновляем сумму для следующего пикселя
val xLeft = (x - radius).coerceIn(0, w - 1)
val xRight = (x + radius + 1).coerceIn(0, w - 1)
val leftPixel = pixels[offset + xLeft]
val rightPixel = pixels[offset + xRight]
sumA += ((rightPixel shr 24) and 0xff) - ((leftPixel shr 24) and 0xff)
sumR += ((rightPixel shr 16) and 0xff) - ((leftPixel shr 16) and 0xff)
sumG += ((rightPixel shr 8) and 0xff) - ((leftPixel shr 8) and 0xff)
sumB += (rightPixel and 0xff) - (leftPixel and 0xff)
}
}
private fun blurColumn(pixels: IntArray, x: Int, w: Int, h: Int, radius: Int) {
var sumR = 0
var sumG = 0
var sumB = 0
var sumA = 0
val dv = radius * 2 + 1
// Инициализация суммы
for (i in -radius..radius) {
val y = i.coerceIn(0, h - 1)
val pixel = pixels[y * w + x]
sumA += (pixel shr 24) and 0xff
sumR += (pixel shr 16) and 0xff
sumG += (pixel shr 8) and 0xff
sumB += pixel and 0xff
}
// Применяем blur
for (y in 0 until h) {
val offset = y * w + x
pixels[offset] = ((sumA / dv) shl 24) or
((sumR / dv) shl 16) or
((sumG / dv) shl 8) or
(sumB / dv)
// Обновляем сумму для следующего пикселя
val yTop = (y - radius).coerceIn(0, h - 1)
val yBottom = (y + radius + 1).coerceIn(0, h - 1)
val topPixel = pixels[yTop * w + x]
val bottomPixel = pixels[yBottom * w + x]
sumA += ((bottomPixel shr 24) and 0xff) - ((topPixel shr 24) and 0xff)
sumR += ((bottomPixel shr 16) and 0xff) - ((topPixel shr 16) and 0xff)
sumG += ((bottomPixel shr 8) and 0xff) - ((topPixel shr 8) and 0xff)
sumB += (bottomPixel and 0xff) - (topPixel and 0xff)
}
}

View File

@@ -1,5 +1,6 @@
package com.rosetta.messenger.ui.settings
import android.app.Activity
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
@@ -38,6 +39,7 @@ import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import com.rosetta.messenger.utils.ImageCropHelper
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
@@ -53,6 +55,7 @@ import com.rosetta.messenger.biometric.BiometricPreferences
import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.utils.AvatarFileManager
import com.rosetta.messenger.ui.components.AvatarImage
import com.rosetta.messenger.ui.components.BlurredAvatarBackground
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
@@ -166,50 +169,50 @@ fun ProfileScreen(
// Состояние меню аватара для установки фото профиля
var showAvatarMenu by remember { mutableStateOf(false) }
// Image picker launcher для выбора аватара
val imagePickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent()
) { uri: Uri? ->
Log.d(TAG, "🖼️ Image picker result: uri=$uri")
uri?.let {
// URI выбранного изображения (до crop)
var selectedImageUri by remember { mutableStateOf<Uri?>(null) }
// Launcher для обрезки изображения (uCrop)
val cropLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult()
) { result ->
Log.d(TAG, "✂️ Crop result: resultCode=${result.resultCode}")
val croppedUri = ImageCropHelper.getCroppedImageUri(result)
val error = ImageCropHelper.getCropError(result)
if (croppedUri != null) {
Log.d(TAG, "✅ Cropped image URI: $croppedUri")
scope.launch {
try {
Log.d(TAG, "📁 Reading image from URI: $uri")
// Читаем файл изображения
val inputStream = context.contentResolver.openInputStream(uri)
// Читаем обрезанное изображение
val inputStream = context.contentResolver.openInputStream(croppedUri)
val imageBytes = inputStream?.readBytes()
inputStream?.close()
Log.d(TAG, "📊 Image bytes read: ${imageBytes?.size ?: 0} bytes")
Log.d(TAG, "📊 Cropped image bytes: ${imageBytes?.size ?: 0} bytes")
if (imageBytes != null) {
Log.d(TAG, "🔄 Converting to PNG Base64...")
// Конвертируем в PNG Base64 (кросс-платформенная совместимость)
Log.d(TAG, "🔄 Converting cropped image to PNG Base64...")
val base64Png = withContext(Dispatchers.IO) {
AvatarFileManager.imagePrepareForNetworkTransfer(context, imageBytes)
}
Log.d(TAG, "✅ Converted to Base64: ${base64Png.length} chars")
Log.d(TAG, "🔐 Avatar repository available: ${avatarRepository != null}")
Log.d(TAG, "👤 Current public key: ${accountPublicKey.take(16)}...")
// Сохраняем аватар через репозиторий
Log.d(TAG, "💾 Calling avatarRepository.changeMyAvatar()...")
avatarRepository?.changeMyAvatar(base64Png)
Log.d(TAG, "🎉 Avatar update completed")
// Показываем успешное сообщение
android.widget.Toast.makeText(
context,
"Avatar updated successfully",
android.widget.Toast.LENGTH_SHORT
).show()
} else {
Log.e(TAG, "❌ Image bytes are null")
}
} catch (e: Exception) {
Log.e(TAG, "❌ Failed to upload avatar", e)
Log.e(TAG, "❌ Failed to process cropped avatar", e)
android.widget.Toast.makeText(
context,
"Failed to update avatar: ${e.message}",
@@ -217,6 +220,27 @@ fun ProfileScreen(
).show()
}
}
} else if (error != null) {
Log.e(TAG, "❌ Crop error", error)
android.widget.Toast.makeText(
context,
"Failed to crop image: ${error.message}",
android.widget.Toast.LENGTH_LONG
).show()
} else {
Log.w(TAG, "⚠️ Crop cancelled")
}
}
// Image picker launcher - после выбора открываем crop
val imagePickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent()
) { uri: Uri? ->
Log.d(TAG, "🖼️ Image picker result: uri=$uri")
uri?.let {
// Запускаем uCrop для обрезки
val cropIntent = ImageCropHelper.createCropIntent(context, it, isDarkTheme)
cropLauncher.launch(cropIntent)
} ?: Log.w(TAG, "⚠️ URI is null, image picker cancelled")
}
@@ -535,10 +559,18 @@ private fun CollapsingProfileHeader(
modifier = Modifier
.fillMaxWidth()
.height(headerHeight)
.drawBehind {
drawRect(avatarColors.backgroundColor)
}
) {
// ═══════════════════════════════════════════════════════════
// 🎨 BLURRED AVATAR BACKGROUND (вместо цвета)
// ═══════════════════════════════════════════════════════════
BlurredAvatarBackground(
publicKey = publicKey,
avatarRepository = avatarRepository,
fallbackColor = avatarColors.backgroundColor,
blurRadius = 25f,
alpha = 0.3f
)
// ═══════════════════════════════════════════════════════════
// 🔙 BACK BUTTON
// ═══════════════════════════════════════════════════════════