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

@@ -90,6 +90,9 @@ dependencies {
implementation("io.coil-kt:coil-compose:2.5.0") implementation("io.coil-kt:coil-compose:2.5.0")
implementation("io.coil-kt:coil-gif:2.5.0") // For animated WebP/GIF support implementation("io.coil-kt:coil-gif:2.5.0") // For animated WebP/GIF support
// uCrop for image cropping
implementation("com.github.yalantis:ucrop:2.2.8")
// Blurhash for image placeholders // Blurhash for image placeholders
implementation("com.vanniktech:blurhash:0.1.0") implementation("com.vanniktech:blurhash:0.1.0")

View File

@@ -51,6 +51,12 @@
<meta-data <meta-data
android:name="com.google.firebase.messaging.default_notification_color" android:name="com.google.firebase.messaging.default_notification_color"
android:resource="@color/primary_blue" /> android:resource="@color/primary_blue" />
<!-- UCrop Activity for image cropping -->
<activity
android:name="com.yalantis.ucrop.UCropActivity"
android:screenOrientation="portrait"
android:theme="@style/Theme.RosettaAndroid.UCrop" />
</application> </application>
</manifest> </manifest>

View File

@@ -38,6 +38,7 @@ import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.network.ProtocolState import com.rosetta.messenger.network.ProtocolState
import com.rosetta.messenger.ui.components.AppleEmojiText 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.onboarding.PrimaryBlue import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
@@ -385,12 +386,25 @@ fun ChatsListScreen(
) )
val headerColor = avatarColors.backgroundColor val headerColor = avatarColors.backgroundColor
// Header с размытым фоном аватарки
Box( Box(
modifier = modifier = Modifier.fillMaxWidth()
Modifier.fillMaxWidth() ) {
.background( // ═══════════════════════════════════════════════════════════
color = headerColor // 🎨 BLURRED AVATAR BACKGROUND (на всю область header)
) // ═══════════════════════════════════════════════════════════
BlurredAvatarBackground(
publicKey = accountPublicKey,
avatarRepository = avatarRepository,
fallbackColor = headerColor,
blurRadius = 40f,
alpha = 0.6f
)
// Content поверх фона
Column(
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding() .statusBarsPadding()
.padding( .padding(
top = 16.dp, top = 16.dp,
@@ -398,8 +412,7 @@ fun ChatsListScreen(
end = 20.dp, end = 20.dp,
bottom = 20.dp bottom = 20.dp
) )
) { ) {
Column {
// Avatar - используем AvatarImage // Avatar - используем AvatarImage
Box( Box(
modifier = 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 package com.rosetta.messenger.ui.settings
import android.app.Activity
import android.content.ClipData import android.content.ClipData
import android.content.ClipboardManager import android.content.ClipboardManager
import android.content.Context 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.LocalConfiguration
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import com.rosetta.messenger.utils.ImageCropHelper
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign 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.repository.AvatarRepository
import com.rosetta.messenger.utils.AvatarFileManager import com.rosetta.messenger.utils.AvatarFileManager
import com.rosetta.messenger.ui.components.AvatarImage import com.rosetta.messenger.ui.components.AvatarImage
import com.rosetta.messenger.ui.components.BlurredAvatarBackground
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -166,50 +169,50 @@ fun ProfileScreen(
// Состояние меню аватара для установки фото профиля // Состояние меню аватара для установки фото профиля
var showAvatarMenu by remember { mutableStateOf(false) } var showAvatarMenu by remember { mutableStateOf(false) }
// Image picker launcher для выбора аватара // URI выбранного изображения (до crop)
val imagePickerLauncher = rememberLauncherForActivityResult( var selectedImageUri by remember { mutableStateOf<Uri?>(null) }
contract = ActivityResultContracts.GetContent()
) { uri: Uri? -> // Launcher для обрезки изображения (uCrop)
Log.d(TAG, "🖼️ Image picker result: uri=$uri") val cropLauncher = rememberLauncherForActivityResult(
uri?.let { 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 { scope.launch {
try { try {
Log.d(TAG, "📁 Reading image from URI: $uri") // Читаем обрезанное изображение
// Читаем файл изображения val inputStream = context.contentResolver.openInputStream(croppedUri)
val inputStream = context.contentResolver.openInputStream(uri)
val imageBytes = inputStream?.readBytes() val imageBytes = inputStream?.readBytes()
inputStream?.close() 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) { if (imageBytes != null) {
Log.d(TAG, "🔄 Converting to PNG Base64...") Log.d(TAG, "🔄 Converting cropped image to PNG Base64...")
// Конвертируем в PNG Base64 (кросс-платформенная совместимость)
val base64Png = withContext(Dispatchers.IO) { val base64Png = withContext(Dispatchers.IO) {
AvatarFileManager.imagePrepareForNetworkTransfer(context, imageBytes) AvatarFileManager.imagePrepareForNetworkTransfer(context, imageBytes)
} }
Log.d(TAG, "✅ Converted to Base64: ${base64Png.length} chars") 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) avatarRepository?.changeMyAvatar(base64Png)
Log.d(TAG, "🎉 Avatar update completed") Log.d(TAG, "🎉 Avatar update completed")
// Показываем успешное сообщение
android.widget.Toast.makeText( android.widget.Toast.makeText(
context, context,
"Avatar updated successfully", "Avatar updated successfully",
android.widget.Toast.LENGTH_SHORT android.widget.Toast.LENGTH_SHORT
).show() ).show()
} else {
Log.e(TAG, "❌ Image bytes are null")
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "❌ Failed to upload avatar", e) Log.e(TAG, "❌ Failed to process cropped avatar", e)
android.widget.Toast.makeText( android.widget.Toast.makeText(
context, context,
"Failed to update avatar: ${e.message}", "Failed to update avatar: ${e.message}",
@@ -217,6 +220,27 @@ fun ProfileScreen(
).show() ).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") } ?: Log.w(TAG, "⚠️ URI is null, image picker cancelled")
} }
@@ -535,10 +559,18 @@ private fun CollapsingProfileHeader(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(headerHeight) .height(headerHeight)
.drawBehind {
drawRect(avatarColors.backgroundColor)
}
) { ) {
// ═══════════════════════════════════════════════════════════
// 🎨 BLURRED AVATAR BACKGROUND (вместо цвета)
// ═══════════════════════════════════════════════════════════
BlurredAvatarBackground(
publicKey = publicKey,
avatarRepository = avatarRepository,
fallbackColor = avatarColors.backgroundColor,
blurRadius = 25f,
alpha = 0.3f
)
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
// 🔙 BACK BUTTON // 🔙 BACK BUTTON
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════

View File

@@ -0,0 +1,107 @@
package com.rosetta.messenger.utils
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Color
import android.net.Uri
import androidx.activity.result.ActivityResult
import androidx.core.content.FileProvider
import com.yalantis.ucrop.UCrop
import java.io.File
/**
* Помощник для работы с обрезкой изображений через uCrop
*/
object ImageCropHelper {
private const val CROP_IMAGE_FILE_NAME = "cropped_avatar.png"
/**
* Создает Intent для запуска uCrop
* @param context Контекст
* @param sourceUri URI исходного изображения
* @param isDarkTheme Темная тема
* @return Intent для запуска uCrop Activity
*/
fun createCropIntent(
context: Context,
sourceUri: Uri,
isDarkTheme: Boolean
): Intent {
// Создаем файл для результата crop
val destinationFile = File(context.cacheDir, CROP_IMAGE_FILE_NAME)
val destinationUri = Uri.fromFile(destinationFile)
// Настройки uCrop
val options = UCrop.Options().apply {
// Круглый overlay для аватара
setCircleDimmedLayer(true)
// Показываем сетку
setShowCropGrid(true)
setShowCropFrame(true)
// Цвета в зависимости от темы
if (isDarkTheme) {
setToolbarColor(Color.parseColor("#1A1A1A"))
setStatusBarColor(Color.parseColor("#1A1A1A"))
setActiveControlsWidgetColor(Color.parseColor("#0A84FF"))
setToolbarWidgetColor(Color.WHITE)
setRootViewBackgroundColor(Color.parseColor("#1A1A1A"))
setDimmedLayerColor(Color.parseColor("#CC000000"))
} else {
setToolbarColor(Color.WHITE)
setStatusBarColor(Color.WHITE)
setActiveControlsWidgetColor(Color.parseColor("#007AFF"))
setToolbarWidgetColor(Color.BLACK)
setRootViewBackgroundColor(Color.WHITE)
setDimmedLayerColor(Color.parseColor("#99000000"))
}
// Скрываем кнопку поворота по желанию (можно оставить)
setFreeStyleCropEnabled(false)
// Качество сжатия
setCompressionFormat(Bitmap.CompressFormat.PNG)
setCompressionQuality(100)
// Заголовок
setToolbarTitle("Crop Avatar")
// Скрываем bottom controls если нужно
setHideBottomControls(false)
}
return UCrop.of(sourceUri, destinationUri)
.withAspectRatio(1f, 1f) // Квадратный crop для аватара
.withMaxResultSize(512, 512) // Максимальный размер
.withOptions(options)
.getIntent(context)
}
/**
* Извлекает результат crop из ActivityResult
* @param result Результат Activity
* @return URI обрезанного изображения или null при ошибке/отмене
*/
fun getCroppedImageUri(result: ActivityResult): Uri? {
return if (result.resultCode == Activity.RESULT_OK && result.data != null) {
UCrop.getOutput(result.data!!)
} else {
null
}
}
/**
* Получает ошибку crop если есть
*/
fun getCropError(result: ActivityResult): Throwable? {
return if (result.data != null) {
UCrop.getError(result.data!!)
} else {
null
}
}
}

View File

@@ -6,5 +6,15 @@
<item name="android:navigationBarColor">@color/splash_background</item> <item name="android:navigationBarColor">@color/splash_background</item>
</style> </style>
<!-- Theme for UCrop Activity (requires AppCompat) -->
<style name="Theme.RosettaAndroid.UCrop" parent="Theme.AppCompat.Light.NoActionBar">
<item name="android:windowBackground">@color/splash_background</item>
<item name="android:statusBarColor">@color/splash_background</item>
<item name="android:navigationBarColor">@color/splash_background</item>
<item name="colorPrimary">@color/primary_blue</item>
<item name="colorPrimaryDark">@color/primary_blue</item>
<item name="colorAccent">@color/primary_blue</item>
</style>
<color name="splash_background">#1B1B1B</color> <color name="splash_background">#1B1B1B</color>
</resources> </resources>

View File

@@ -10,6 +10,7 @@ dependencyResolutionManagement {
repositories { repositories {
google() google()
mavenCentral() mavenCentral()
maven { url = uri("https://jitpack.io") }
} }
} }