Профиль и группы: фиксированные табы, fast-scroll с датой и Apple Emoji
This commit is contained in:
@@ -23,8 +23,8 @@ val gitShortSha = safeGitOutput("rev-parse", "--short", "HEAD") ?: "unknown"
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Rosetta versioning — bump here on each release
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
val rosettaVersionName = "1.1.2"
|
||||
val rosettaVersionCode = 14 // Increment on each release
|
||||
val rosettaVersionName = "1.1.3"
|
||||
val rosettaVersionCode = 15 // Increment on each release
|
||||
|
||||
android {
|
||||
namespace = "com.rosetta.messenger"
|
||||
|
||||
@@ -2919,14 +2919,15 @@ fun ChatItem(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
AppleEmojiText(
|
||||
text = chat.name,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = textColor,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f)
|
||||
overflow = android.text.TextUtils.TruncateAt.END,
|
||||
modifier = Modifier.weight(1f),
|
||||
enableLinks = false
|
||||
)
|
||||
|
||||
if (isMuted) {
|
||||
@@ -3722,13 +3723,14 @@ fun DialogItemContent(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
AppleEmojiText(
|
||||
text = displayName,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 16.sp,
|
||||
color = textColor,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
overflow = android.text.TextUtils.TruncateAt.END,
|
||||
enableLinks = false
|
||||
)
|
||||
if (isGroupDialog) {
|
||||
Spacer(modifier = Modifier.width(5.dp))
|
||||
|
||||
@@ -14,6 +14,7 @@ import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -30,6 +31,10 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.itemsIndexed as gridItemsIndexed
|
||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
@@ -82,7 +87,6 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
@@ -124,6 +128,7 @@ import com.rosetta.messenger.ui.chats.components.ImageViewerScreen
|
||||
import com.rosetta.messenger.ui.chats.components.ViewableImage
|
||||
import com.rosetta.messenger.ui.components.AppleEmojiText
|
||||
import com.rosetta.messenger.ui.components.AvatarImage
|
||||
import com.rosetta.messenger.ui.components.SharedMediaFastScrollOverlay
|
||||
import com.rosetta.messenger.ui.components.VerifiedBadge
|
||||
import com.rosetta.messenger.ui.icons.TelegramIcons
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
@@ -133,9 +138,13 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import androidx.core.view.WindowCompat
|
||||
import org.json.JSONArray
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.UUID
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.ceil
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private enum class GroupInfoTab(val title: String) {
|
||||
MEMBERS("Members"),
|
||||
@@ -370,6 +379,8 @@ fun GroupInfoScreen(
|
||||
var showImageViewer by rememberSaveable(dialogPublicKey) { mutableStateOf(false) }
|
||||
var imageViewerInitialIndex by rememberSaveable(dialogPublicKey) { mutableStateOf(0) }
|
||||
var imageViewerSourceBounds by remember(dialogPublicKey) { mutableStateOf<ImageSourceBounds?>(null) }
|
||||
val groupMediaGridState = rememberLazyGridState()
|
||||
var groupMediaFastScrollHintDismissed by rememberSaveable(dialogPublicKey) { mutableStateOf(false) }
|
||||
|
||||
val groupTitle = remember(groupEntity, groupUser.title) {
|
||||
groupEntity?.title?.trim().takeUnless { it.isNullOrBlank() }
|
||||
@@ -1134,55 +1145,95 @@ fun GroupInfoScreen(
|
||||
} else {
|
||||
val mediaColumns = 3
|
||||
val mediaSpacing = 1.dp
|
||||
val mediaScreenWidth = LocalConfiguration.current.screenWidthDp.dp
|
||||
val mediaCellSize =
|
||||
(mediaScreenWidth - mediaSpacing * (mediaColumns - 1)) / mediaColumns
|
||||
val mediaIndexedRows = remember(groupMediaItems) {
|
||||
groupMediaItems.chunked(mediaColumns).mapIndexed { idx, row -> idx to row }
|
||||
val mediaRowsCount = remember(groupMediaItems.size) {
|
||||
ceil(groupMediaItems.size / mediaColumns.toFloat()).toInt().coerceAtLeast(1)
|
||||
}
|
||||
val mediaFastScrollVisible by remember(groupMediaItems.size, groupMediaGridState) {
|
||||
derivedStateOf {
|
||||
val visibleItems = groupMediaGridState.layoutInfo.visibleItemsInfo
|
||||
if (visibleItems.isEmpty()) return@derivedStateOf false
|
||||
val cellHeight = visibleItems.first().size.height
|
||||
val viewportHeight = groupMediaGridState.layoutInfo.viewportEndOffset -
|
||||
groupMediaGridState.layoutInfo.viewportStartOffset
|
||||
mediaRowsCount * cellHeight > viewportHeight
|
||||
}
|
||||
}
|
||||
val mediaFastScrollProgress by remember(groupMediaItems.size, groupMediaGridState) {
|
||||
derivedStateOf {
|
||||
val visibleItems = groupMediaGridState.layoutInfo.visibleItemsInfo
|
||||
if (visibleItems.isEmpty()) return@derivedStateOf 0f
|
||||
val cellHeight = visibleItems.first().size.height
|
||||
if (cellHeight <= 0) return@derivedStateOf 0f
|
||||
val viewportHeight = groupMediaGridState.layoutInfo.viewportEndOffset -
|
||||
groupMediaGridState.layoutInfo.viewportStartOffset
|
||||
val totalHeight = mediaRowsCount * cellHeight
|
||||
val maxScroll = (totalHeight - viewportHeight).coerceAtLeast(1)
|
||||
val firstRow = groupMediaGridState.firstVisibleItemIndex / mediaColumns
|
||||
val scrollY = firstRow * cellHeight + groupMediaGridState.firstVisibleItemScrollOffset
|
||||
(scrollY.toFloat() / maxScroll.toFloat()).coerceIn(0f, 1f)
|
||||
}
|
||||
}
|
||||
val mediaFastScrollMonthLabel by remember(groupMediaItems, groupMediaGridState.firstVisibleItemIndex) {
|
||||
derivedStateOf {
|
||||
if (groupMediaItems.isEmpty()) return@derivedStateOf ""
|
||||
val index = groupMediaGridState.firstVisibleItemIndex.coerceIn(0, groupMediaItems.lastIndex)
|
||||
formatMediaMonthLabel(groupMediaItems[index].timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(bottom = 20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(mediaSpacing)
|
||||
) {
|
||||
items(mediaIndexedRows, key = { (idx, _) -> "group_media_row_$idx" }) { (rowIdx, rowMedia) ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(mediaSpacing)
|
||||
) {
|
||||
rowMedia.forEachIndexed { colIdx, mediaItem ->
|
||||
val globalIndex = rowIdx * mediaColumns + colIdx
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(mediaCellSize)
|
||||
.clip(RoundedCornerShape(0.dp))
|
||||
.background(if (isDarkTheme) Color(0xFF141416) else Color(0xFFE6E7EB))
|
||||
) {
|
||||
ImageAttachment(
|
||||
attachment = mediaItem.attachment,
|
||||
chachaKey = mediaItem.chachaKey,
|
||||
privateKey = currentUserPrivateKey,
|
||||
senderPublicKey = mediaItem.senderPublicKey,
|
||||
isOutgoing = mediaItem.senderPublicKey == currentUserPublicKey,
|
||||
isDarkTheme = isDarkTheme,
|
||||
timestamp = Date(mediaItem.timestamp),
|
||||
showTimeOverlay = false,
|
||||
fillMaxSize = true,
|
||||
onImageClick = { _, bounds ->
|
||||
imageViewerInitialIndex = globalIndex
|
||||
imageViewerSourceBounds = bounds
|
||||
showImageViewer = true
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
repeat(mediaColumns - rowMedia.size) {
|
||||
Spacer(modifier = Modifier.size(mediaCellSize))
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(mediaColumns),
|
||||
state = groupMediaGridState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(bottom = 20.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(mediaSpacing),
|
||||
verticalArrangement = Arrangement.spacedBy(mediaSpacing)
|
||||
) {
|
||||
gridItemsIndexed(groupMediaItems, key = { _, item -> item.key }) { index, mediaItem ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
.clip(RoundedCornerShape(0.dp))
|
||||
.background(if (isDarkTheme) Color(0xFF141416) else Color(0xFFE6E7EB))
|
||||
) {
|
||||
ImageAttachment(
|
||||
attachment = mediaItem.attachment,
|
||||
chachaKey = mediaItem.chachaKey,
|
||||
privateKey = currentUserPrivateKey,
|
||||
senderPublicKey = mediaItem.senderPublicKey,
|
||||
isOutgoing = mediaItem.senderPublicKey == currentUserPublicKey,
|
||||
isDarkTheme = isDarkTheme,
|
||||
timestamp = Date(mediaItem.timestamp),
|
||||
showTimeOverlay = false,
|
||||
fillMaxSize = true,
|
||||
onImageClick = { _, bounds ->
|
||||
imageViewerInitialIndex = index
|
||||
imageViewerSourceBounds = bounds
|
||||
showImageViewer = true
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SharedMediaFastScrollOverlay(
|
||||
visible = mediaFastScrollVisible,
|
||||
progress = mediaFastScrollProgress,
|
||||
monthLabel = mediaFastScrollMonthLabel,
|
||||
isDarkTheme = isDarkTheme,
|
||||
showHint = mediaFastScrollVisible && !groupMediaFastScrollHintDismissed,
|
||||
onHintDismissed = { groupMediaFastScrollHintDismissed = true },
|
||||
onDragProgressChanged = { fraction ->
|
||||
if (groupMediaItems.isEmpty()) return@SharedMediaFastScrollOverlay
|
||||
val targetRow = ((mediaRowsCount - 1) * fraction).roundToInt()
|
||||
val targetIndex = (targetRow * mediaColumns).coerceIn(0, groupMediaItems.lastIndex)
|
||||
scope.launch {
|
||||
groupMediaGridState.scrollToItem(targetIndex)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1868,6 +1919,12 @@ private fun shortPublicKey(publicKey: String): String {
|
||||
return "${trimmed.take(6)}...${trimmed.takeLast(4)}"
|
||||
}
|
||||
|
||||
private fun formatMediaMonthLabel(timestamp: Long): String {
|
||||
return runCatching {
|
||||
SimpleDateFormat("MMMM yyyy", Locale.getDefault()).format(Date(timestamp))
|
||||
}.getOrElse { "" }
|
||||
}
|
||||
|
||||
private fun decryptStoredMessageText(encryptedText: String, privateKey: String): String {
|
||||
if (encryptedText.isBlank()) return ""
|
||||
if (privateKey.isBlank()) return encryptedText
|
||||
|
||||
@@ -207,11 +207,14 @@ fun AvatarPlaceholder(
|
||||
.background(avatarColors.backgroundColor),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
AppleEmojiText(
|
||||
text = avatarText,
|
||||
color = avatarColors.textColor,
|
||||
fontSize = fontSize ?: (size.value / 2.5).sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = android.text.TextUtils.TruncateAt.END,
|
||||
enableLinks = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
package com.rosetta.messenger.ui.components
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
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.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.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.UnfoldMore
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
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.graphics.Color
|
||||
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.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.rosetta.messenger.R
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Composable
|
||||
fun SharedMediaFastScrollOverlay(
|
||||
visible: Boolean,
|
||||
progress: Float,
|
||||
monthLabel: String,
|
||||
isDarkTheme: Boolean,
|
||||
showHint: Boolean,
|
||||
onHintDismissed: () -> Unit,
|
||||
onDragProgressChanged: (Float) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
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() }
|
||||
|
||||
var trackHeightPx 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) }
|
||||
|
||||
val normalizedProgress = progress.coerceIn(0f, 1f)
|
||||
|
||||
LaunchedEffect(showHint) {
|
||||
if (showHint) {
|
||||
hintVisible = true
|
||||
delay(4000)
|
||||
hintVisible = false
|
||||
onHintDismissed()
|
||||
} else {
|
||||
hintVisible = false
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(normalizedProgress, isDragging) {
|
||||
if (!isDragging) {
|
||||
dragProgress = normalizedProgress
|
||||
}
|
||||
}
|
||||
|
||||
val shownProgress = if (isDragging) dragProgress else normalizedProgress
|
||||
val trackTravelPx = (trackHeightPx - thumbHeightPx).coerceAtLeast(1f)
|
||||
val thumbOffsetY = (trackTravelPx * shownProgress).coerceIn(0f, trackTravelPx)
|
||||
|
||||
Box(
|
||||
modifier = modifier.fillMaxSize()
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = hintVisible && !isDragging,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterEnd)
|
||||
.padding(end = 44.dp)
|
||||
) {
|
||||
SharedMediaFastScrollHint(isDarkTheme = isDarkTheme)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterEnd)
|
||||
.padding(end = 8.dp)
|
||||
.fillMaxHeight(0.86f)
|
||||
.width(40.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)
|
||||
}
|
||||
detectDragGestures(
|
||||
onDragStart = { offset ->
|
||||
isDragging = true
|
||||
if (hintVisible) {
|
||||
hintVisible = false
|
||||
onHintDismissed()
|
||||
}
|
||||
updateProgress(offset.y)
|
||||
},
|
||||
onDragEnd = { isDragging = false },
|
||||
onDragCancel = { isDragging = false },
|
||||
onDrag = { change, _ ->
|
||||
updateProgress(change.position.y)
|
||||
}
|
||||
)
|
||||
}
|
||||
) {
|
||||
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(
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.width(3.dp)
|
||||
.fillMaxHeight()
|
||||
.clip(RoundedCornerShape(2.dp))
|
||||
.background(trackColor)
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SharedMediaFastScrollHint(isDarkTheme: Boolean) {
|
||||
val background = if (isDarkTheme) Color(0xE6212934) else Color(0xE8263F63)
|
||||
val iconBackground = if (isDarkTheme) Color(0x553A4A60) else Color(0x553A5A84)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(background)
|
||||
.padding(horizontal = 10.dp, vertical = 8.dp)
|
||||
.widthIn(max = 250.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(22.dp)
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.background(iconBackground),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.UnfoldMore,
|
||||
contentDescription = null,
|
||||
tint = Color.White.copy(alpha = 0.92f),
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.shared_media_fast_scroll_hint),
|
||||
color = Color.White.copy(alpha = 0.92f),
|
||||
fontSize = 13.sp,
|
||||
lineHeight = 16.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@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)
|
||||
)
|
||||
}
|
||||
@@ -29,12 +29,14 @@ import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Block
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -90,6 +92,7 @@ import com.rosetta.messenger.ui.chats.components.ImageViewerScreen
|
||||
import com.rosetta.messenger.ui.chats.components.ViewableImage
|
||||
import com.rosetta.messenger.ui.components.AvatarImage
|
||||
import com.rosetta.messenger.ui.components.BlurredAvatarBackground
|
||||
import com.rosetta.messenger.ui.components.SharedMediaFastScrollOverlay
|
||||
import com.rosetta.messenger.ui.components.VerifiedBadge
|
||||
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
||||
import com.rosetta.messenger.utils.AttachmentFileManager
|
||||
@@ -111,6 +114,7 @@ import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import kotlin.math.ceil
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
// Collapsing header constants
|
||||
private val EXPANDED_HEADER_HEIGHT_OTHER = 280.dp
|
||||
@@ -557,12 +561,72 @@ fun OtherProfileScreen(
|
||||
val mediaScreenWidth = LocalConfiguration.current.screenWidthDp.dp
|
||||
val mediaCellSize = (mediaScreenWidth - mediaSpacing * (mediaColumns - 1)) / mediaColumns
|
||||
val mediaDecodeSemaphore = remember { Semaphore(4) }
|
||||
val profileListState = rememberLazyListState()
|
||||
var mediaFastScrollHintDismissed by rememberSaveable(user.publicKey) { mutableStateOf(false) }
|
||||
// Use stable key for bitmap cache - don't recreate on size change
|
||||
val mediaBitmapStates = remember { mutableStateMapOf<String, android.graphics.Bitmap?>() }
|
||||
// Pre-compute indexed rows to avoid O(n) indexOf calls
|
||||
val mediaIndexedRows = remember(sharedContent.mediaPhotos) {
|
||||
sharedContent.mediaPhotos.chunked(mediaColumns).mapIndexed { idx, row -> idx to row }
|
||||
}
|
||||
val mediaRowsCount = remember(sharedContent.mediaPhotos.size) {
|
||||
ceil(sharedContent.mediaPhotos.size / mediaColumns.toFloat()).toInt().coerceAtLeast(1)
|
||||
}
|
||||
val mediaRowHeightPx = with(density) { (mediaCellSize + mediaSpacing).toPx() }
|
||||
val mediaMaxScrollPx by remember(sharedContent.mediaPhotos.size, mediaRowHeightPx, profileListState.layoutInfo) {
|
||||
derivedStateOf {
|
||||
val viewportHeight = (profileListState.layoutInfo.viewportEndOffset - profileListState.layoutInfo.viewportStartOffset)
|
||||
.toFloat()
|
||||
.coerceAtLeast(1f)
|
||||
(mediaRowsCount * mediaRowHeightPx - viewportHeight).coerceAtLeast(1f)
|
||||
}
|
||||
}
|
||||
val tabPagerScrollPx by remember(selectedTab, profileListState.layoutInfo, mediaMaxScrollPx) {
|
||||
derivedStateOf {
|
||||
if (selectedTab != OtherProfileTab.MEDIA) return@derivedStateOf 0f
|
||||
val tabPagerInfo =
|
||||
profileListState.layoutInfo.visibleItemsInfo.firstOrNull { it.key == "tab_pager" }
|
||||
when {
|
||||
tabPagerInfo != null -> (-tabPagerInfo.offset).toFloat().coerceIn(0f, mediaMaxScrollPx)
|
||||
profileListState.layoutInfo.visibleItemsInfo.any { it.key == "bottom_spacer" } -> mediaMaxScrollPx
|
||||
else -> 0f
|
||||
}
|
||||
}
|
||||
}
|
||||
val mediaFastScrollProgress by remember(
|
||||
selectedTab,
|
||||
tabPagerScrollPx,
|
||||
mediaMaxScrollPx
|
||||
) {
|
||||
derivedStateOf {
|
||||
if (selectedTab != OtherProfileTab.MEDIA) {
|
||||
0f
|
||||
} else {
|
||||
(tabPagerScrollPx / mediaMaxScrollPx).coerceIn(0f, 1f)
|
||||
}
|
||||
}
|
||||
}
|
||||
val mediaFastScrollMonthLabel by remember(
|
||||
selectedTab,
|
||||
sharedContent.mediaPhotos,
|
||||
tabPagerScrollPx,
|
||||
mediaRowHeightPx,
|
||||
mediaRowsCount
|
||||
) {
|
||||
derivedStateOf {
|
||||
if (selectedTab != OtherProfileTab.MEDIA || sharedContent.mediaPhotos.isEmpty()) return@derivedStateOf ""
|
||||
val currentOffsetPx = tabPagerScrollPx
|
||||
val rowIndex = if (mediaRowHeightPx <= 0f) 0 else (currentOffsetPx / mediaRowHeightPx).toInt()
|
||||
.coerceIn(0, mediaRowsCount - 1)
|
||||
val itemIndex = (rowIndex * mediaColumns).coerceIn(0, sharedContent.mediaPhotos.lastIndex)
|
||||
formatMediaMonthLabel(sharedContent.mediaPhotos[itemIndex].timestamp)
|
||||
}
|
||||
}
|
||||
val mediaFastScrollVisible by remember(selectedTab, sharedContent.mediaPhotos.size, mediaMaxScrollPx) {
|
||||
derivedStateOf {
|
||||
selectedTab == OtherProfileTab.MEDIA && sharedContent.mediaPhotos.isNotEmpty() && mediaMaxScrollPx > 24f
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
@@ -572,6 +636,7 @@ fun OtherProfileScreen(
|
||||
) {
|
||||
// Scrollable content
|
||||
LazyColumn(
|
||||
state = profileListState,
|
||||
modifier =
|
||||
Modifier.fillMaxSize()
|
||||
.padding(
|
||||
@@ -724,14 +789,20 @@ fun OtherProfileScreen(
|
||||
)
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 📚 SHARED CONTENT (без разделителя — сразу табы)
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
OtherProfileSharedTabs(
|
||||
selectedTab = selectedTab,
|
||||
onTabSelected = { tab -> selectedTab = tab },
|
||||
isDarkTheme = isDarkTheme
|
||||
)
|
||||
}
|
||||
|
||||
stickyHeader(key = "shared_tabs") {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(backgroundColor)
|
||||
) {
|
||||
OtherProfileSharedTabs(
|
||||
selectedTab = selectedTab,
|
||||
onTabSelected = { tab -> selectedTab = tab },
|
||||
isDarkTheme = isDarkTheme
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════
|
||||
@@ -959,6 +1030,22 @@ 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)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 🎨 COLLAPSING HEADER with METABALL EFFECT
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
@@ -1481,6 +1568,12 @@ private fun formatFileSize(sizeBytes: Long?): String {
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatMediaMonthLabel(timestamp: Long): String {
|
||||
return runCatching {
|
||||
SimpleDateFormat("MMMM yyyy", Locale.getDefault()).format(Date(timestamp))
|
||||
}.getOrElse { "" }
|
||||
}
|
||||
|
||||
private fun formatTimestamp(timestamp: Long): String {
|
||||
return SimpleDateFormat("dd MMM, HH:mm", Locale.getDefault()).format(Date(timestamp))
|
||||
}
|
||||
|
||||
@@ -1,4 +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>
|
||||
</resources>
|
||||
|
||||
Reference in New Issue
Block a user