diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt index d7f146f..c0ad443 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt @@ -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)) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt index e717bbf..9b31a02 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt @@ -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(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 diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/AvatarImage.kt b/app/src/main/java/com/rosetta/messenger/ui/components/AvatarImage.kt index 4b8c06a..5c27ae1 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/components/AvatarImage.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/components/AvatarImage.kt @@ -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 ) } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/SharedMediaFastScrollOverlay.kt b/app/src/main/java/com/rosetta/messenger/ui/components/SharedMediaFastScrollOverlay.kt new file mode 100644 index 0000000..7ea06d3 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/ui/components/SharedMediaFastScrollOverlay.kt @@ -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) + ) +} diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt index c96fb1b..9deb12f 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt @@ -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() } // 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)) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1c843e7..8ab8542 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,4 +1,5 @@ Rosetta + You can hold and move this bar for faster scrolling.