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") } } }