Релиз 1.1.6: сессии, аватарки и интерфейсные исправления
This commit is contained in:
@@ -935,7 +935,7 @@ fun ChatsListScreen(
|
||||
VerifiedBadge(
|
||||
verified = if (accountVerified > 0) accountVerified else 1,
|
||||
size = 15,
|
||||
badgeTint = Color(0xFFACD2F9)
|
||||
badgeTint = PrimaryBlue
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -3891,7 +3891,9 @@ fun DialogItemContent(
|
||||
VerifiedBadge(
|
||||
verified = if (dialog.verified > 0) dialog.verified else 1,
|
||||
size = 16,
|
||||
modifier = Modifier.offset(y = (-2).dp)
|
||||
modifier = Modifier.offset(y = (-2).dp),
|
||||
isDarkTheme = isDarkTheme,
|
||||
badgeTint = PrimaryBlue
|
||||
)
|
||||
}
|
||||
// 🔒 Красная иконка замочка для заблокированных пользователей
|
||||
|
||||
@@ -2,6 +2,11 @@ package com.rosetta.messenger.ui.components
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapShader
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Matrix
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Shader
|
||||
import android.os.Build
|
||||
import android.renderscript.Allocation
|
||||
import android.renderscript.Element
|
||||
@@ -32,6 +37,7 @@ import com.rosetta.messenger.repository.AvatarRepository
|
||||
import com.rosetta.messenger.utils.AvatarFileManager
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.math.abs
|
||||
|
||||
/**
|
||||
* Компонент для отображения размытого фона аватарки
|
||||
@@ -63,7 +69,11 @@ fun BoxScope.BlurredAvatarBackground(
|
||||
LaunchedEffect(avatarKey, publicKey) {
|
||||
val currentAvatars = avatars
|
||||
val newOriginal = withContext(Dispatchers.IO) {
|
||||
if (currentAvatars.isNotEmpty()) {
|
||||
// Keep system account blur source identical to the visible avatar drawable.
|
||||
// This prevents offset/parallax mismatches when server avatar payload differs.
|
||||
if (MessageRepository.isSystemAccount(publicKey)) {
|
||||
loadSystemAvatarBitmap(context, publicKey)
|
||||
} else if (currentAvatars.isNotEmpty()) {
|
||||
AvatarFileManager.base64ToBitmap(currentAvatars.first().base64Data)
|
||||
} else {
|
||||
loadSystemAvatarBitmap(context, publicKey)
|
||||
@@ -183,7 +193,78 @@ private fun loadSystemAvatarBitmap(context: Context, publicKey: String): Bitmap?
|
||||
val drawable = AppCompatResources.getDrawable(context, resId) ?: return null
|
||||
val width = drawable.intrinsicWidth.takeIf { it > 0 } ?: 256
|
||||
val height = drawable.intrinsicHeight.takeIf { it > 0 } ?: 256
|
||||
return drawable.toBitmap(width = width, height = height, config = Bitmap.Config.ARGB_8888)
|
||||
val source = drawable.toBitmap(width = width, height = height, config = Bitmap.Config.ARGB_8888)
|
||||
return recenterSystemBlurSource(source)
|
||||
}
|
||||
|
||||
private data class FocusCenter(val x: Float, val y: Float)
|
||||
|
||||
private fun recenterSystemBlurSource(source: Bitmap): Bitmap {
|
||||
val focusCenter = detectSystemFocusCenter(source) ?: return source
|
||||
val dx = source.width / 2f - focusCenter.x
|
||||
val dy = source.height / 2f - focusCenter.y
|
||||
|
||||
if (abs(dx) < 0.5f && abs(dy) < 0.5f) return source
|
||||
|
||||
val output = Bitmap.createBitmap(source.width, source.height, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(output)
|
||||
val shader = BitmapShader(source, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP)
|
||||
val matrix = Matrix().apply { setTranslate(dx, dy) }
|
||||
shader.setLocalMatrix(matrix)
|
||||
val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
this.shader = shader
|
||||
isFilterBitmap = true
|
||||
isDither = true
|
||||
}
|
||||
canvas.drawRect(0f, 0f, source.width.toFloat(), source.height.toFloat(), paint)
|
||||
return output
|
||||
}
|
||||
|
||||
private fun detectSystemFocusCenter(source: Bitmap): FocusCenter? {
|
||||
val width = source.width
|
||||
val height = source.height
|
||||
if (width < 2 || height < 2) return null
|
||||
|
||||
val cornerColors = intArrayOf(
|
||||
source.getPixel(0, 0),
|
||||
source.getPixel(width - 1, 0),
|
||||
source.getPixel(0, height - 1),
|
||||
source.getPixel(width - 1, height - 1)
|
||||
)
|
||||
val bgR = cornerColors.map { android.graphics.Color.red(it) }.average().toFloat()
|
||||
val bgG = cornerColors.map { android.graphics.Color.green(it) }.average().toFloat()
|
||||
val bgB = cornerColors.map { android.graphics.Color.blue(it) }.average().toFloat()
|
||||
|
||||
val step = (minOf(width, height) / 140).coerceAtLeast(1)
|
||||
val threshold = 54f
|
||||
var weightedX = 0f
|
||||
var weightedY = 0f
|
||||
var totalWeight = 0f
|
||||
|
||||
var y = 0
|
||||
while (y < height) {
|
||||
var x = 0
|
||||
while (x < width) {
|
||||
val pixel = source.getPixel(x, y)
|
||||
val dr = abs(android.graphics.Color.red(pixel) - bgR)
|
||||
val dg = abs(android.graphics.Color.green(pixel) - bgG)
|
||||
val db = abs(android.graphics.Color.blue(pixel) - bgB)
|
||||
val weight = (dr + dg + db) - threshold
|
||||
if (weight > 0f) {
|
||||
weightedX += x * weight
|
||||
weightedY += y * weight
|
||||
totalWeight += weight
|
||||
}
|
||||
x += step
|
||||
}
|
||||
y += step
|
||||
}
|
||||
|
||||
if (totalWeight <= 0f) return null
|
||||
return FocusCenter(
|
||||
x = weightedX / totalWeight,
|
||||
y = weightedY / totalWeight
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.rosetta.messenger.ui.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.*
|
||||
@@ -16,6 +15,7 @@ import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import com.rosetta.messenger.R
|
||||
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
||||
import com.rosetta.messenger.ui.theme.LocalRosettaIsDarkTheme
|
||||
|
||||
/**
|
||||
* Значок верификации пользователя
|
||||
@@ -23,14 +23,14 @@ import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
||||
*
|
||||
* @param verified Уровень верификации (0 = нет, 1 = стандартная, 2+ = особая)
|
||||
* @param size Размер значка в dp
|
||||
* @param isDarkTheme Тема приложения (если не передано - используется системная)
|
||||
* @param isDarkTheme Тема приложения (если не передано - вычисляется из MaterialTheme)
|
||||
*/
|
||||
@Composable
|
||||
fun VerifiedBadge(
|
||||
verified: Int,
|
||||
size: Int = 16,
|
||||
modifier: Modifier = Modifier,
|
||||
isDarkTheme: Boolean = isSystemInDarkTheme(),
|
||||
isDarkTheme: Boolean = LocalRosettaIsDarkTheme.current,
|
||||
badgeTint: Color? = null
|
||||
) {
|
||||
if (verified <= 0) return
|
||||
|
||||
@@ -19,6 +19,7 @@ import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
@@ -51,6 +52,7 @@ import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
@@ -192,6 +194,9 @@ fun OtherProfileScreen(
|
||||
var isBlocked by remember { mutableStateOf(false) }
|
||||
var showAvatarMenu by remember { mutableStateOf(false) }
|
||||
var showImageViewer by remember { mutableStateOf(false) }
|
||||
var showAvatarViewer by remember { mutableStateOf(false) }
|
||||
var avatarViewerBitmap by remember { mutableStateOf<android.graphics.Bitmap?>(null) }
|
||||
var avatarViewerTimestamp by remember { mutableStateOf(0L) }
|
||||
var imageViewerInitialIndex by remember { mutableIntStateOf(0) }
|
||||
var selectedTab by remember { mutableStateOf(OtherProfileTab.MEDIA) }
|
||||
val pagerState = rememberPagerState(
|
||||
@@ -212,12 +217,12 @@ fun OtherProfileScreen(
|
||||
snapshotFlow { pagerState.currentPage }.collect { page ->
|
||||
selectedTab = OtherProfileTab.entries[page]
|
||||
// Swipe-back only on first tab (Media); on other tabs pager handles swipe
|
||||
onSwipeBackEnabledChanged(page == 0 && !showImageViewer)
|
||||
onSwipeBackEnabledChanged(page == 0 && !showImageViewer && !showAvatarViewer)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(showImageViewer) {
|
||||
onSwipeBackEnabledChanged(!showImageViewer && pagerState.currentPage == 0)
|
||||
LaunchedEffect(showImageViewer, showAvatarViewer) {
|
||||
onSwipeBackEnabledChanged(!showImageViewer && !showAvatarViewer && pagerState.currentPage == 0)
|
||||
}
|
||||
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF)
|
||||
@@ -1123,6 +1128,43 @@ fun OtherProfileScreen(
|
||||
}
|
||||
}
|
||||
},
|
||||
onAvatarLongPress = {
|
||||
coroutineScope.launch {
|
||||
when {
|
||||
hasAvatar && avatars.isNotEmpty() -> {
|
||||
val first = avatars.first()
|
||||
avatarViewerTimestamp = first.timestamp
|
||||
val bitmap = withContext(Dispatchers.IO) {
|
||||
AvatarFileManager.base64ToBitmap(first.base64Data)
|
||||
}
|
||||
if (bitmap != null) {
|
||||
avatarViewerBitmap = bitmap
|
||||
showAvatarViewer = true
|
||||
}
|
||||
}
|
||||
user.publicKey == MessageRepository.SYSTEM_SAFE_PUBLIC_KEY -> {
|
||||
val bitmap = withContext(Dispatchers.IO) {
|
||||
BitmapFactory.decodeResource(context.resources, R.drawable.safe_account)
|
||||
}
|
||||
if (bitmap != null) {
|
||||
avatarViewerTimestamp = 0L
|
||||
avatarViewerBitmap = bitmap
|
||||
showAvatarViewer = true
|
||||
}
|
||||
}
|
||||
user.publicKey == MessageRepository.SYSTEM_UPDATES_PUBLIC_KEY -> {
|
||||
val bitmap = withContext(Dispatchers.IO) {
|
||||
BitmapFactory.decodeResource(context.resources, R.drawable.updates_account)
|
||||
}
|
||||
if (bitmap != null) {
|
||||
avatarViewerTimestamp = 0L
|
||||
avatarViewerBitmap = bitmap
|
||||
showAvatarViewer = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
backgroundBlurColorId = backgroundBlurColorId
|
||||
)
|
||||
|
||||
@@ -1135,6 +1177,16 @@ fun OtherProfileScreen(
|
||||
isDarkTheme = isDarkTheme
|
||||
)
|
||||
}
|
||||
|
||||
FullScreenAvatarViewer(
|
||||
isVisible = showAvatarViewer,
|
||||
onDismiss = { showAvatarViewer = false },
|
||||
displayName = user.title.ifBlank { user.publicKey.take(10) },
|
||||
avatarTimestamp = avatarViewerTimestamp,
|
||||
avatarBitmap = avatarViewerBitmap,
|
||||
publicKey = user.publicKey,
|
||||
isDarkTheme = isDarkTheme
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1815,6 +1867,7 @@ private fun CollapsingOtherProfileHeader(
|
||||
onBlockToggle: () -> Unit,
|
||||
avatarRepository: AvatarRepository? = null,
|
||||
onClearChat: () -> Unit,
|
||||
onAvatarLongPress: () -> Unit = {},
|
||||
backgroundBlurColorId: String = "avatar"
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
@@ -1943,7 +1996,12 @@ private fun CollapsingOtherProfileHeader(
|
||||
shape = RoundedCornerShape(cornerRadius)
|
||||
clip = true
|
||||
}
|
||||
.background(avatarColors.backgroundColor),
|
||||
.background(avatarColors.backgroundColor)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onLongPress = { onAvatarLongPress() }
|
||||
)
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (publicKey == MessageRepository.SYSTEM_SAFE_PUBLIC_KEY) {
|
||||
|
||||
@@ -2074,7 +2074,13 @@ fun FullScreenAvatarViewer(
|
||||
val dateText = remember(avatarTimestamp) {
|
||||
if (avatarTimestamp > 0) {
|
||||
val sdf = SimpleDateFormat("MMMM d, yyyy 'at' h:mm a", Locale.ENGLISH)
|
||||
sdf.format(Date(avatarTimestamp * 1000))
|
||||
val normalizedTimestampMs = when {
|
||||
avatarTimestamp >= 1_000_000_000_000_000_000L -> avatarTimestamp / 1_000_000
|
||||
avatarTimestamp >= 1_000_000_000_000_000L -> avatarTimestamp / 1_000
|
||||
avatarTimestamp >= 1_000_000_000_000L -> avatarTimestamp
|
||||
else -> avatarTimestamp * 1_000
|
||||
}
|
||||
sdf.format(Date(normalizedTimestampMs))
|
||||
} else ""
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ import androidx.core.view.WindowCompat
|
||||
import com.rosetta.messenger.ui.utils.NavigationModeUtils
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
val LocalRosettaIsDarkTheme = staticCompositionLocalOf { true }
|
||||
|
||||
private val DarkColorScheme = darkColorScheme(
|
||||
primary = DarkPrimary,
|
||||
secondary = Accent,
|
||||
@@ -78,9 +80,11 @@ fun RosettaAndroidTheme(
|
||||
}
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography,
|
||||
content = content
|
||||
)
|
||||
CompositionLocalProvider(LocalRosettaIsDarkTheme provides darkTheme) {
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user