diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 0256197..29ace29 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -90,6 +90,9 @@ dependencies {
implementation("io.coil-kt:coil-compose:2.5.0")
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
implementation("com.vanniktech:blurhash:0.1.0")
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index fdcf930..b508ec8 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -51,6 +51,12 @@
+
+
+
diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt
index 18dfb7d..400d56b 100644
--- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt
+++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt
@@ -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 =
diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/BlurredAvatarBackground.kt b/app/src/main/java/com/rosetta/messenger/ui/components/BlurredAvatarBackground.kt
new file mode 100644
index 0000000..a6d49e4
--- /dev/null
+++ b/app/src/main/java/com/rosetta/messenger/ui/components/BlurredAvatarBackground.kt
@@ -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(null) }
+ var blurredBitmap by remember(avatars) { mutableStateOf(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)
+ }
+}
+
diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt
index 8eb4fc9..3102183 100644
--- a/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt
+++ b/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt
@@ -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(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
// ═══════════════════════════════════════════════════════════
diff --git a/app/src/main/java/com/rosetta/messenger/utils/ImageCropHelper.kt b/app/src/main/java/com/rosetta/messenger/utils/ImageCropHelper.kt
new file mode 100644
index 0000000..be1f2fb
--- /dev/null
+++ b/app/src/main/java/com/rosetta/messenger/utils/ImageCropHelper.kt
@@ -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
+ }
+ }
+}
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
index e383f92..471145d 100644
--- a/app/src/main/res/values/themes.xml
+++ b/app/src/main/res/values/themes.xml
@@ -6,5 +6,15 @@
- @color/splash_background
+
+
+
#1B1B1B
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 1689754..2f3f047 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -10,6 +10,7 @@ dependencyResolutionManagement {
repositories {
google()
mavenCentral()
+ maven { url = uri("https://jitpack.io") }
}
}