Профиль и группы: фиксированные табы, 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,
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))

View File

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

View File

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

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