Релиз 1.1.6: сессии, аватарки и интерфейсные исправления

This commit is contained in:
2026-03-10 23:25:03 +05:00
parent 9d0cab3f53
commit 810913b28e
9 changed files with 202 additions and 32 deletions

View File

@@ -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
)
}
// 🔒 Красная иконка замочка для заблокированных пользователей

View File

@@ -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
)
}
/**

View File

@@ -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

View File

@@ -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) {

View File

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

View File

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