Профиль и группы: фиксированные табы, fast-scroll с датой и Apple Emoji

This commit is contained in:
2026-03-06 18:05:17 +05:00
parent 59d71c9717
commit 6429a61ad0
6 changed files with 457 additions and 60 deletions

View File

@@ -2919,14 +2919,15 @@ fun ChatItem(
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text( AppleEmojiText(
text = chat.name, text = chat.name,
fontSize = 16.sp, fontSize = 16.sp,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
color = textColor, color = textColor,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = android.text.TextUtils.TruncateAt.END,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f),
enableLinks = false
) )
if (isMuted) { if (isMuted) {
@@ -3722,13 +3723,14 @@ fun DialogItemContent(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text( AppleEmojiText(
text = displayName, text = displayName,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
fontSize = 16.sp, fontSize = 16.sp,
color = textColor, color = textColor,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = android.text.TextUtils.TruncateAt.END,
enableLinks = false
) )
if (isGroupDialog) { if (isGroupDialog) {
Spacer(modifier = Modifier.width(5.dp)) Spacer(modifier = Modifier.width(5.dp))

View File

@@ -14,6 +14,7 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box 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.statusBarsPadding
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn 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.items
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.rememberScrollState 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.Offset
import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalUriHandler 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.chats.components.ViewableImage
import com.rosetta.messenger.ui.components.AppleEmojiText import com.rosetta.messenger.ui.components.AppleEmojiText
import com.rosetta.messenger.ui.components.AvatarImage 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.components.VerifiedBadge
import com.rosetta.messenger.ui.icons.TelegramIcons import com.rosetta.messenger.ui.icons.TelegramIcons
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
@@ -133,9 +138,13 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import org.json.JSONArray import org.json.JSONArray
import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale
import java.util.UUID import java.util.UUID
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.ceil
import kotlin.math.roundToInt
private enum class GroupInfoTab(val title: String) { private enum class GroupInfoTab(val title: String) {
MEMBERS("Members"), MEMBERS("Members"),
@@ -370,6 +379,8 @@ fun GroupInfoScreen(
var showImageViewer by rememberSaveable(dialogPublicKey) { mutableStateOf(false) } var showImageViewer by rememberSaveable(dialogPublicKey) { mutableStateOf(false) }
var imageViewerInitialIndex by rememberSaveable(dialogPublicKey) { mutableStateOf(0) } var imageViewerInitialIndex by rememberSaveable(dialogPublicKey) { mutableStateOf(0) }
var imageViewerSourceBounds by remember(dialogPublicKey) { mutableStateOf<ImageSourceBounds?>(null) } var imageViewerSourceBounds by remember(dialogPublicKey) { mutableStateOf<ImageSourceBounds?>(null) }
val groupMediaGridState = rememberLazyGridState()
var groupMediaFastScrollHintDismissed by rememberSaveable(dialogPublicKey) { mutableStateOf(false) }
val groupTitle = remember(groupEntity, groupUser.title) { val groupTitle = remember(groupEntity, groupUser.title) {
groupEntity?.title?.trim().takeUnless { it.isNullOrBlank() } groupEntity?.title?.trim().takeUnless { it.isNullOrBlank() }
@@ -1134,55 +1145,95 @@ fun GroupInfoScreen(
} else { } else {
val mediaColumns = 3 val mediaColumns = 3
val mediaSpacing = 1.dp val mediaSpacing = 1.dp
val mediaScreenWidth = LocalConfiguration.current.screenWidthDp.dp val mediaRowsCount = remember(groupMediaItems.size) {
val mediaCellSize = ceil(groupMediaItems.size / mediaColumns.toFloat()).toInt().coerceAtLeast(1)
(mediaScreenWidth - mediaSpacing * (mediaColumns - 1)) / mediaColumns }
val mediaIndexedRows = remember(groupMediaItems) { val mediaFastScrollVisible by remember(groupMediaItems.size, groupMediaGridState) {
groupMediaItems.chunked(mediaColumns).mapIndexed { idx, row -> idx to row } 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( Box(modifier = Modifier.fillMaxSize()) {
modifier = Modifier.fillMaxSize(), LazyVerticalGrid(
contentPadding = PaddingValues(bottom = 20.dp), columns = GridCells.Fixed(mediaColumns),
verticalArrangement = Arrangement.spacedBy(mediaSpacing) state = groupMediaGridState,
) { modifier = Modifier.fillMaxSize(),
items(mediaIndexedRows, key = { (idx, _) -> "group_media_row_$idx" }) { (rowIdx, rowMedia) -> contentPadding = PaddingValues(bottom = 20.dp),
Row( horizontalArrangement = Arrangement.spacedBy(mediaSpacing),
modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(mediaSpacing)
horizontalArrangement = Arrangement.spacedBy(mediaSpacing) ) {
) { gridItemsIndexed(groupMediaItems, key = { _, item -> item.key }) { index, mediaItem ->
rowMedia.forEachIndexed { colIdx, mediaItem -> Box(
val globalIndex = rowIdx * mediaColumns + colIdx modifier = Modifier
Box( .fillMaxWidth()
modifier = Modifier .aspectRatio(1f)
.size(mediaCellSize) .clip(RoundedCornerShape(0.dp))
.clip(RoundedCornerShape(0.dp)) .background(if (isDarkTheme) Color(0xFF141416) else Color(0xFFE6E7EB))
.background(if (isDarkTheme) Color(0xFF141416) else Color(0xFFE6E7EB)) ) {
) { ImageAttachment(
ImageAttachment( attachment = mediaItem.attachment,
attachment = mediaItem.attachment, chachaKey = mediaItem.chachaKey,
chachaKey = mediaItem.chachaKey, privateKey = currentUserPrivateKey,
privateKey = currentUserPrivateKey, senderPublicKey = mediaItem.senderPublicKey,
senderPublicKey = mediaItem.senderPublicKey, isOutgoing = mediaItem.senderPublicKey == currentUserPublicKey,
isOutgoing = mediaItem.senderPublicKey == currentUserPublicKey, isDarkTheme = isDarkTheme,
isDarkTheme = isDarkTheme, timestamp = Date(mediaItem.timestamp),
timestamp = Date(mediaItem.timestamp), showTimeOverlay = false,
showTimeOverlay = false, fillMaxSize = true,
fillMaxSize = true, onImageClick = { _, bounds ->
onImageClick = { _, bounds -> imageViewerInitialIndex = index
imageViewerInitialIndex = globalIndex imageViewerSourceBounds = bounds
imageViewerSourceBounds = bounds showImageViewer = true
showImageViewer = true }
} )
)
}
}
repeat(mediaColumns - rowMedia.size) {
Spacer(modifier = Modifier.size(mediaCellSize))
} }
} }
} }
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)}" 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 { private fun decryptStoredMessageText(encryptedText: String, privateKey: String): String {
if (encryptedText.isBlank()) return "" if (encryptedText.isBlank()) return ""
if (privateKey.isBlank()) return encryptedText if (privateKey.isBlank()) return encryptedText

View File

@@ -207,11 +207,14 @@ fun AvatarPlaceholder(
.background(avatarColors.backgroundColor), .background(avatarColors.backgroundColor),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text( AppleEmojiText(
text = avatarText, text = avatarText,
color = avatarColors.textColor, color = avatarColors.textColor,
fontSize = fontSize ?: (size.value / 2.5).sp, fontSize = fontSize ?: (size.value / 2.5).sp,
fontWeight = FontWeight.Medium fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = android.text.TextUtils.TruncateAt.END,
enableLinks = false
) )
} }
} }

View File

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

View File

@@ -29,12 +29,14 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Block import androidx.compose.material.icons.outlined.Block
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier 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.chats.components.ViewableImage
import com.rosetta.messenger.ui.components.AvatarImage import com.rosetta.messenger.ui.components.AvatarImage
import com.rosetta.messenger.ui.components.BlurredAvatarBackground 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.components.VerifiedBadge
import com.rosetta.messenger.ui.onboarding.PrimaryBlue import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import com.rosetta.messenger.utils.AttachmentFileManager import com.rosetta.messenger.utils.AttachmentFileManager
@@ -111,6 +114,7 @@ import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
import kotlin.math.ceil import kotlin.math.ceil
import kotlin.math.roundToInt
// Collapsing header constants // Collapsing header constants
private val EXPANDED_HEADER_HEIGHT_OTHER = 280.dp private val EXPANDED_HEADER_HEIGHT_OTHER = 280.dp
@@ -557,12 +561,72 @@ fun OtherProfileScreen(
val mediaScreenWidth = LocalConfiguration.current.screenWidthDp.dp val mediaScreenWidth = LocalConfiguration.current.screenWidthDp.dp
val mediaCellSize = (mediaScreenWidth - mediaSpacing * (mediaColumns - 1)) / mediaColumns val mediaCellSize = (mediaScreenWidth - mediaSpacing * (mediaColumns - 1)) / mediaColumns
val mediaDecodeSemaphore = remember { Semaphore(4) } 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 // Use stable key for bitmap cache - don't recreate on size change
val mediaBitmapStates = remember { mutableStateMapOf<String, android.graphics.Bitmap?>() } val mediaBitmapStates = remember { mutableStateMapOf<String, android.graphics.Bitmap?>() }
// Pre-compute indexed rows to avoid O(n) indexOf calls // Pre-compute indexed rows to avoid O(n) indexOf calls
val mediaIndexedRows = remember(sharedContent.mediaPhotos) { val mediaIndexedRows = remember(sharedContent.mediaPhotos) {
sharedContent.mediaPhotos.chunked(mediaColumns).mapIndexed { idx, row -> idx to row } 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( Box(
modifier = modifier =
@@ -572,6 +636,7 @@ fun OtherProfileScreen(
) { ) {
// Scrollable content // Scrollable content
LazyColumn( LazyColumn(
state = profileListState,
modifier = modifier =
Modifier.fillMaxSize() Modifier.fillMaxSize()
.padding( .padding(
@@ -724,14 +789,20 @@ fun OtherProfileScreen(
) )
} }
// ═══════════════════════════════════════════════════════════ }
// 📚 SHARED CONTENT (без разделителя — сразу табы)
// ═══════════════════════════════════════════════════════════ stickyHeader(key = "shared_tabs") {
OtherProfileSharedTabs( Box(
selectedTab = selectedTab, modifier = Modifier
onTabSelected = { tab -> selectedTab = tab }, .fillMaxWidth()
isDarkTheme = isDarkTheme .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 // 🎨 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 { private fun formatTimestamp(timestamp: Long): String {
return SimpleDateFormat("dd MMM, HH:mm", Locale.getDefault()).format(Date(timestamp)) return SimpleDateFormat("dd MMM, HH:mm", Locale.getDefault()).format(Date(timestamp))
} }

View File

@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="app_name">Rosetta</string> <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> </resources>