Улучшение отображения аватаров: добавлена поддержка текста с эмодзи и улучшена логика отображения в AvatarImage. Обновлен SharedMediaFastScrollOverlay для корректного отображения при изменении размера. Исправлено сообщение подсказки в строках.

This commit is contained in:
2026-03-06 19:19:01 +05:00
parent 8bce15cc19
commit 6a269f93db
4 changed files with 240 additions and 118 deletions

View File

@@ -199,6 +199,7 @@ fun AvatarPlaceholder(
} else {
getAvatarText(publicKey)
}
val resolvedFontSize = fontSize ?: (size.value / 2.5).sp
Box(
modifier = Modifier
@@ -207,15 +208,34 @@ fun AvatarPlaceholder(
.background(avatarColors.backgroundColor),
contentAlignment = Alignment.Center
) {
AppleEmojiText(
text = avatarText,
color = avatarColors.textColor,
fontSize = fontSize ?: (size.value / 2.5).sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = android.text.TextUtils.TruncateAt.END,
enableLinks = false
)
if (containsEmojiAvatarText(avatarText)) {
AppleEmojiText(
text = avatarText,
color = avatarColors.textColor,
fontSize = resolvedFontSize,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = android.text.TextUtils.TruncateAt.END,
enableLinks = false
)
} else {
Text(
text = avatarText,
color = avatarColors.textColor,
fontSize = resolvedFontSize,
fontWeight = FontWeight.Medium,
maxLines = 1
)
}
}
}
private fun containsEmojiAvatarText(text: String): Boolean {
if (text.contains(":emoji_", ignoreCase = true)) return true
return text.any { ch ->
val type = java.lang.Character.getType(ch.code)
type == java.lang.Character.SURROGATE.toInt() ||
type == java.lang.Character.OTHER_SYMBOL.toInt()
}
}

View File

@@ -11,11 +11,13 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.UnfoldMore
@@ -28,15 +30,21 @@ import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -57,12 +65,14 @@ fun SharedMediaFastScrollOverlay(
) {
if (!visible) return
val thumbWidth = 24.dp
val thumbHeight = 44.dp
val thumbHeightPx = with(androidx.compose.ui.platform.LocalDensity.current) { thumbHeight.toPx() }
val monthBubbleOffsetXPx = with(androidx.compose.ui.platform.LocalDensity.current) { (-90).dp.roundToPx() }
val density = androidx.compose.ui.platform.LocalDensity.current
val handleSize = 48.dp
val handleSizePx = with(density) { handleSize.toPx() }
val bubbleOffsetX = with(density) { (-96).dp.roundToPx() }
var rootHeightPx by remember { mutableIntStateOf(0) }
var trackHeightPx by remember { mutableIntStateOf(0) }
var monthBubbleHeightPx by remember { mutableIntStateOf(0) }
var isDragging by remember { mutableStateOf(false) }
var dragProgress by remember { mutableFloatStateOf(progress.coerceIn(0f, 1f)) }
var hintVisible by remember(showHint) { mutableStateOf(showHint) }
@@ -87,11 +97,18 @@ fun SharedMediaFastScrollOverlay(
}
val shownProgress = if (isDragging) dragProgress else normalizedProgress
val trackTravelPx = (trackHeightPx - thumbHeightPx).coerceAtLeast(1f)
val thumbOffsetY = (trackTravelPx * shownProgress).coerceIn(0f, trackTravelPx)
val trackTravelPx = (trackHeightPx - handleSizePx).coerceAtLeast(1f)
val handleOffsetYPx = (trackTravelPx * shownProgress).coerceIn(0f, trackTravelPx)
val latestShownProgress by rememberUpdatedState(shownProgress)
val latestHandleOffsetYPx by rememberUpdatedState(handleOffsetYPx)
val handleCenterYPx = handleOffsetYPx + handleSizePx / 2f
val trackTopPx = ((rootHeightPx - trackHeightPx) / 2f).coerceAtLeast(0f)
val bubbleY = (trackTopPx + handleCenterYPx - monthBubbleHeightPx / 2f).roundToInt()
Box(
modifier = modifier.fillMaxSize()
modifier = modifier
.fillMaxSize()
.onSizeChanged { rootHeightPx = it.height }
) {
AnimatedVisibility(
visible = hintVisible && !isDragging,
@@ -99,7 +116,7 @@ fun SharedMediaFastScrollOverlay(
exit = fadeOut(),
modifier = Modifier
.align(Alignment.CenterEnd)
.padding(end = 44.dp)
.padding(end = 60.dp)
) {
SharedMediaFastScrollHint(isDarkTheme = isDarkTheme)
}
@@ -107,105 +124,172 @@ fun SharedMediaFastScrollOverlay(
Box(
modifier = Modifier
.align(Alignment.CenterEnd)
.padding(end = 8.dp)
.padding(end = 2.dp)
.fillMaxHeight(0.86f)
.width(40.dp)
.width(56.dp)
.onSizeChanged { trackHeightPx = it.height }
.pointerInput(trackHeightPx, thumbHeightPx) {
if (trackHeightPx <= 0) return@pointerInput
fun updateProgress(y: Float) {
val fraction = ((y - thumbHeightPx / 2f) / trackTravelPx).coerceIn(0f, 1f)
dragProgress = fraction
onDragProgressChanged(fraction)
}
.pointerInput(trackTravelPx, handleSizePx) {
if (trackTravelPx <= 0f) return@pointerInput
var dragFromHandle = false
detectDragGestures(
onDragStart = { offset ->
val handleLeft = size.width - handleSizePx
val handleRight = size.width.toFloat()
val handleTop = latestHandleOffsetYPx
val handleBottom = handleTop + handleSizePx
dragFromHandle =
offset.x in handleLeft..handleRight &&
offset.y in handleTop..handleBottom
if (!dragFromHandle) return@detectDragGestures
isDragging = true
dragProgress = latestShownProgress
if (hintVisible) {
hintVisible = false
onHintDismissed()
}
updateProgress(offset.y)
},
onDragEnd = { isDragging = false },
onDragCancel = { isDragging = false },
onDrag = { change, _ ->
updateProgress(change.position.y)
onDragEnd = {
if (dragFromHandle) {
isDragging = false
}
dragFromHandle = false
},
onDragCancel = {
if (dragFromHandle) {
isDragging = false
}
dragFromHandle = false
},
onDrag = { change, dragAmount ->
if (!dragFromHandle) return@detectDragGestures
change.consume()
val nextProgress =
(dragProgress + dragAmount.y / trackTravelPx).coerceIn(0f, 1f)
if (nextProgress != dragProgress) {
dragProgress = nextProgress
onDragProgressChanged(nextProgress)
}
}
)
}
) {
val trackColor = if (isDarkTheme) Color(0x5A7C8798) else Color(0x663F4F64)
val thumbColor = if (isDarkTheme) Color(0xFF29364A) else Color(0xFF2B4E73)
val thumbBorderColor = if (isDarkTheme) Color(0x6688A7CC) else Color(0x554B7DB0)
Box(
TelegramDateHandle(
isDarkTheme = isDarkTheme,
modifier = Modifier
.align(Alignment.Center)
.width(3.dp)
.fillMaxHeight()
.clip(RoundedCornerShape(2.dp))
.background(trackColor)
.align(Alignment.TopEnd)
.offset { IntOffset(0, handleOffsetYPx.roundToInt()) }
.size(handleSize)
)
}
Box(
modifier = Modifier
.align(Alignment.TopCenter)
.offset { IntOffset(0, thumbOffsetY.roundToInt()) }
.size(width = thumbWidth, height = thumbHeight)
.clip(RoundedCornerShape(12.dp))
.background(thumbColor)
.border(1.dp, thumbBorderColor, RoundedCornerShape(12.dp)),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.UnfoldMore,
contentDescription = "Fast scroll handle",
tint = Color.White.copy(alpha = 0.92f),
modifier = Modifier.size(18.dp)
)
}
AnimatedVisibility(
visible = isDragging && monthLabel.isNotBlank(),
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier
.align(Alignment.TopCenter)
.offset {
IntOffset(
monthBubbleOffsetXPx,
(thumbOffsetY + 6f).roundToInt()
)
}
) {
SharedMediaMonthPill(
monthLabel = monthLabel,
isDarkTheme = isDarkTheme
)
}
AnimatedVisibility(
visible = isDragging && monthLabel.isNotBlank(),
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier
.align(Alignment.TopEnd)
.offset { IntOffset(bubbleOffsetX, bubbleY) }
) {
SharedMediaMonthBubble(
monthLabel = monthLabel,
isDarkTheme = isDarkTheme,
onMeasured = { monthBubbleHeightPx = it }
)
}
}
}
@Composable
private fun TelegramDateHandle(isDarkTheme: Boolean, modifier: Modifier = Modifier) {
val handleBackground = if (isDarkTheme) Color(0xFF3D4C63) else Color(0xFFEAF0F8)
val borderColor = if (isDarkTheme) Color(0x668EA0BA) else Color(0x33405673)
val arrowColor = if (isDarkTheme) Color(0xFFF0F4FB) else Color(0xFF4A5A73)
Box(
modifier = modifier
.shadow(8.dp, CircleShape, clip = false)
.clip(CircleShape)
.background(handleBackground)
.border(1.dp, borderColor, CircleShape)
) {
androidx.compose.foundation.Canvas(modifier = Modifier.fillMaxSize()) {
drawTelegramArrowPair(color = arrowColor)
}
}
}
private fun DrawScope.drawTelegramArrowPair(color: Color) {
val centerX = size.width / 2f
val centerY = size.height / 2f
val halfWidth = size.minDimension * 0.12f
val halfHeight = size.minDimension * 0.08f
val gap = size.minDimension * 0.14f
val up = Path().apply {
moveTo(centerX - halfWidth, centerY - gap + halfHeight)
lineTo(centerX + halfWidth, centerY - gap + halfHeight)
lineTo(centerX, centerY - gap - halfHeight)
close()
}
val down = Path().apply {
moveTo(centerX - halfWidth, centerY + gap - halfHeight)
lineTo(centerX + halfWidth, centerY + gap - halfHeight)
lineTo(centerX, centerY + gap + halfHeight)
close()
}
drawPath(path = up, color = color)
drawPath(path = down, color = color)
}
@Composable
private fun SharedMediaMonthBubble(
monthLabel: String,
isDarkTheme: Boolean,
onMeasured: (Int) -> Unit
) {
val bubbleBackground = if (isDarkTheme) Color(0xFF3C4655) else Color(0xFFF1F5FB)
val bubbleBorder = if (isDarkTheme) Color(0x55424F62) else Color(0x223D4D66)
val textColor = if (isDarkTheme) Color(0xFFF2F5FB) else Color(0xFF283548)
Text(
text = monthLabel,
color = textColor,
maxLines = 1,
overflow = TextOverflow.Clip,
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold
),
modifier = Modifier
.onSizeChanged { onMeasured(it.height) }
.clip(RoundedCornerShape(18.dp))
.background(bubbleBackground)
.border(1.dp, bubbleBorder, RoundedCornerShape(18.dp))
.padding(horizontal = 12.dp, vertical = 6.dp)
.widthIn(min = 94.dp)
)
}
@Composable
private fun SharedMediaFastScrollHint(isDarkTheme: Boolean) {
val background = if (isDarkTheme) Color(0xE6212934) else Color(0xE8263F63)
val iconBackground = if (isDarkTheme) Color(0x553A4A60) else Color(0x553A5A84)
val background = if (isDarkTheme) Color(0xEA2A323D) else Color(0xEA26374E)
val iconBackground = if (isDarkTheme) Color(0x553C4656) else Color(0x55324A67)
Row(
modifier = Modifier
.clip(RoundedCornerShape(10.dp))
.clip(RoundedCornerShape(8.dp))
.background(background)
.padding(horizontal = 10.dp, vertical = 8.dp)
.widthIn(max = 250.dp),
.widthIn(max = 300.dp)
.heightIn(min = 34.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Box(
modifier = Modifier
.size(22.dp)
.clip(RoundedCornerShape(6.dp))
.size(20.dp)
.clip(RoundedCornerShape(5.dp))
.background(iconBackground),
contentAlignment = Alignment.Center
) {
@@ -213,7 +297,7 @@ private fun SharedMediaFastScrollHint(isDarkTheme: Boolean) {
imageVector = Icons.Default.UnfoldMore,
contentDescription = null,
tint = Color.White.copy(alpha = 0.92f),
modifier = Modifier.size(14.dp)
modifier = Modifier.size(12.dp)
)
}
Text(
@@ -224,18 +308,3 @@ private fun SharedMediaFastScrollHint(isDarkTheme: Boolean) {
)
}
}
@Composable
private fun SharedMediaMonthPill(monthLabel: String, isDarkTheme: Boolean) {
val background = if (isDarkTheme) Color(0xEE2A3445) else Color(0xEE2B4E73)
Text(
text = monthLabel,
color = Color.White.copy(alpha = 0.95f),
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.background(background)
.padding(horizontal = 12.dp, vertical = 6.dp)
)
}

View File

@@ -52,6 +52,9 @@ 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.layout.ContentScale
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
@@ -67,6 +70,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
@@ -561,6 +565,8 @@ fun OtherProfileScreen(
val mediaScreenWidth = LocalConfiguration.current.screenWidthDp.dp
val mediaCellSize = (mediaScreenWidth - mediaSpacing * (mediaColumns - 1)) / mediaColumns
val mediaDecodeSemaphore = remember { Semaphore(4) }
var rootHeightPx by remember { mutableIntStateOf(0) }
var sharedTabsBottomPx by remember { mutableIntStateOf(0) }
val profileListState = rememberLazyListState()
var mediaFastScrollHintDismissed by rememberSaveable(user.publicKey) { mutableStateOf(false) }
// Use stable key for bitmap cache - don't recreate on size change
@@ -622,9 +628,20 @@ fun OtherProfileScreen(
formatMediaMonthLabel(sharedContent.mediaPhotos[itemIndex].timestamp)
}
}
val mediaFastScrollVisible by remember(selectedTab, sharedContent.mediaPhotos.size, mediaMaxScrollPx) {
val overlayTopPx = sharedTabsBottomPx.coerceIn(0, rootHeightPx)
val overlayHeightPx = (rootHeightPx - overlayTopPx).coerceAtLeast(0)
val mediaFastScrollVisible by remember(
selectedTab,
sharedContent.mediaPhotos.size,
mediaMaxScrollPx,
overlayHeightPx
) {
derivedStateOf {
selectedTab == OtherProfileTab.MEDIA && sharedContent.mediaPhotos.isNotEmpty() && mediaMaxScrollPx > 24f
selectedTab == OtherProfileTab.MEDIA &&
sharedContent.mediaPhotos.isNotEmpty() &&
mediaMaxScrollPx > 24f &&
profileListState.firstVisibleItemIndex >= 1 &&
overlayHeightPx > 0
}
}
@@ -632,6 +649,7 @@ fun OtherProfileScreen(
modifier =
Modifier.fillMaxSize()
.background(backgroundColor)
.onSizeChanged { rootHeightPx = it.height }
.nestedScroll(nestedScrollConnection)
) {
// Scrollable content
@@ -796,6 +814,10 @@ fun OtherProfileScreen(
modifier = Modifier
.fillMaxWidth()
.background(backgroundColor)
.onGloballyPositioned { coords ->
sharedTabsBottomPx =
(coords.positionInRoot().y + coords.size.height).roundToInt()
}
) {
OtherProfileSharedTabs(
selectedTab = selectedTab,
@@ -1030,21 +1052,32 @@ fun OtherProfileScreen(
}
}
SharedMediaFastScrollOverlay(
visible = mediaFastScrollVisible,
progress = mediaFastScrollProgress,
monthLabel = mediaFastScrollMonthLabel,
isDarkTheme = isDarkTheme,
showHint = mediaFastScrollVisible && !mediaFastScrollHintDismissed,
onHintDismissed = { mediaFastScrollHintDismissed = true },
onDragProgressChanged = { fraction ->
if (!mediaFastScrollVisible) return@SharedMediaFastScrollOverlay
val targetOffset = (mediaMaxScrollPx * fraction).roundToInt()
coroutineScope.launch {
profileListState.scrollToItem(index = 2, scrollOffset = targetOffset)
}
}
)
if (overlayHeightPx > 0) {
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.offset { IntOffset(0, overlayTopPx) }
.fillMaxWidth()
.height(with(density) { overlayHeightPx.toDp() })
.clipToBounds()
) {
SharedMediaFastScrollOverlay(
visible = mediaFastScrollVisible,
progress = mediaFastScrollProgress,
monthLabel = mediaFastScrollMonthLabel,
isDarkTheme = isDarkTheme,
showHint = mediaFastScrollVisible && !mediaFastScrollHintDismissed,
onHintDismissed = { mediaFastScrollHintDismissed = true },
onDragProgressChanged = { fraction ->
if (!mediaFastScrollVisible) return@SharedMediaFastScrollOverlay
val targetOffset = (mediaMaxScrollPx * fraction).roundToInt()
coroutineScope.launch {
profileListState.scrollToItem(index = 2, scrollOffset = targetOffset)
}
}
)
}
}
// ═══════════════════════════════════════════════════════════
// 🎨 COLLAPSING HEADER with METABALL EFFECT

View File

@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Rosetta</string>
<string name="shared_media_fast_scroll_hint">You can hold and move this bar for faster scrolling.</string>
<string name="shared_media_fast_scroll_hint">You can hold and move this bar for faster scrolling</string>
</resources>