From ddb6207bb5543ce5e1487d9f5e93fbb0650dd508 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Tue, 3 Mar 2026 19:07:58 +0500 Subject: [PATCH] feat: implement HorizontalPager for tab navigation in OtherProfileScreen --- .../ui/settings/OtherProfileScreen.kt | 413 +++++++++--------- 1 file changed, 218 insertions(+), 195 deletions(-) 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 dcb252e..a162fc0 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 @@ -19,6 +19,8 @@ import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -98,6 +100,7 @@ import compose.icons.tablericons.* import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.flowOf +import androidx.compose.runtime.snapshotFlow import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit @@ -183,6 +186,25 @@ fun OtherProfileScreen( var showImageViewer by remember { mutableStateOf(false) } var imageViewerInitialIndex by remember { mutableIntStateOf(0) } var selectedTab by remember { mutableStateOf(OtherProfileTab.MEDIA) } + val pagerState = rememberPagerState( + initialPage = 0, + pageCount = { OtherProfileTab.entries.size } + ) + + // Tab click → animate pager + LaunchedEffect(selectedTab) { + val page = OtherProfileTab.entries.indexOf(selectedTab) + if (pagerState.currentPage != page) { + pagerState.animateScrollToPage(page) + } + } + + // Pager swipe → update tab + LaunchedEffect(pagerState) { + snapshotFlow { pagerState.currentPage }.collect { page -> + selectedTab = OtherProfileTab.entries[page] + } + } LaunchedEffect(showImageViewer) { onSwipeBackEnabledChanged(!showImageViewer) @@ -711,217 +733,218 @@ fun OtherProfileScreen( } // ══════════════════════════════════════════════════════ - // TAB CONTENT — inlined directly into LazyColumn items - // for true virtualization (only visible items compose) + // TAB CONTENT — HorizontalPager for swipe between tabs + // On first tab (Media) swipe-right triggers swipe-back; + // on other tabs swipe-right goes to the previous tab. // ══════════════════════════════════════════════════════ - when (selectedTab) { - OtherProfileTab.MEDIA -> { - if (sharedContent.mediaPhotos.isEmpty()) { - item(key = "media_empty") { - OtherProfileEmptyState( - animationAssetPath = "lottie/saved.json", - title = "No shared media yet", - subtitle = "Photos from your chat will appear here.", - isDarkTheme = isDarkTheme - ) - } - } else { - items( - items = mediaIndexedRows, - key = { (idx, _) -> "media_row_$idx" } - ) { (rowIdx, rowPhotos) -> - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(mediaSpacing) - ) { - rowPhotos.forEachIndexed { colIdx, media -> - val globalIndex = rowIdx * mediaColumns + colIdx + item(key = "tab_pager") { + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.Top + ) { page -> + when (page) { + // ── MEDIA ── + 0 -> { + if (sharedContent.mediaPhotos.isEmpty()) { + OtherProfileEmptyState( + animationAssetPath = "lottie/saved.json", + title = "No shared media yet", + subtitle = "Photos from your chat will appear here.", + isDarkTheme = isDarkTheme + ) + } else { + Column(modifier = Modifier.fillMaxWidth()) { + mediaIndexedRows.forEach { (rowIdx, rowPhotos) -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(mediaSpacing) + ) { + rowPhotos.forEachIndexed { colIdx, media -> + val globalIndex = rowIdx * mediaColumns + colIdx - // Check cache first - val cachedBitmap = mediaBitmapStates[media.key] - ?: SharedMediaBitmapCache.get(media.key) + val cachedBitmap = mediaBitmapStates[media.key] + ?: SharedMediaBitmapCache.get(media.key) - // Only launch decode for items not yet cached - if (cachedBitmap == null && !mediaBitmapStates.containsKey(media.key)) { - LaunchedEffect(media.key) { - mediaDecodeSemaphore.withPermit { - val bitmap = withContext(Dispatchers.IO) { - resolveSharedPhotoBitmap( - context = context, - media = media, - accountPublicKey = activeAccountPublicKey, - accountPrivateKey = activeAccountPrivateKey - ) + if (cachedBitmap == null && !mediaBitmapStates.containsKey(media.key)) { + LaunchedEffect(media.key) { + mediaDecodeSemaphore.withPermit { + val bitmap = withContext(Dispatchers.IO) { + resolveSharedPhotoBitmap( + context = context, + media = media, + accountPublicKey = activeAccountPublicKey, + accountPrivateKey = activeAccountPrivateKey + ) + } + mediaBitmapStates[media.key] = bitmap + } + } } - mediaBitmapStates[media.key] = bitmap + + val resolvedBitmap = cachedBitmap ?: mediaBitmapStates[media.key] + val model = remember(media.localUri, media.blob) { + resolveSharedMediaModel(media.localUri, media.blob) + } + val previewBitmap = remember(media.preview) { + if (media.preview.isNotBlank()) { + runCatching { + val bytes = Base64.decode(media.preview, Base64.DEFAULT) + BitmapFactory.decodeByteArray(bytes, 0, bytes.size) + }.getOrNull() + } else null + } + val isLoaded = resolvedBitmap != null || model != null + val imageAlpha by animateFloatAsState( + targetValue = if (isLoaded) 1f else 0f, + animationSpec = tween(300), + label = "media_fade" + ) + + Box( + modifier = Modifier + .size(mediaCellSize) + .clip(RoundedCornerShape(0.dp)) + .clickable( + enabled = model != null || resolvedBitmap != null || media.attachmentId.isNotBlank() + ) { + imageViewerInitialIndex = globalIndex + showImageViewer = true + }, + contentAlignment = Alignment.Center + ) { + if (previewBitmap != null) { + Image( + bitmap = previewBitmap.asImageBitmap(), + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } else { + Box( + modifier = Modifier + .fillMaxSize() + .background( + if (isDarkTheme) Color(0xFF1E1E1E) + else Color(0xFFECECEC) + ) + ) + } + if (resolvedBitmap != null) { + Image( + bitmap = resolvedBitmap.asImageBitmap(), + contentDescription = "Shared media", + modifier = Modifier.fillMaxSize().graphicsLayer { alpha = imageAlpha }, + contentScale = ContentScale.Crop + ) + } else if (model != null) { + coil.compose.AsyncImage( + model = model, + contentDescription = "Shared media", + modifier = Modifier.fillMaxSize().graphicsLayer { alpha = imageAlpha }, + contentScale = ContentScale.Crop + ) + } + } + } + repeat(mediaColumns - rowPhotos.size) { + Spacer(modifier = Modifier.size(mediaCellSize)) } } } + } + } + } + // ── FILES ── + 1 -> { + if (sharedContent.files.isEmpty()) { + OtherProfileEmptyState( + animationAssetPath = "lottie/folder.json", + title = "No shared files", + subtitle = "Documents from this chat will appear here.", + isDarkTheme = isDarkTheme + ) + } else { + val fileTextColor = if (isDarkTheme) Color.White else Color.Black + val fileSecondary = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) + val fileDivider = if (isDarkTheme) Color(0xFF333333) else Color(0xFFD9DDE5) - val resolvedBitmap = cachedBitmap ?: mediaBitmapStates[media.key] - val model = remember(media.localUri, media.blob) { - resolveSharedMediaModel(media.localUri, media.blob) - } - // Decode blurred preview from base64 (small image ~4-16px) - val previewBitmap = remember(media.preview) { - if (media.preview.isNotBlank()) { - runCatching { - val bytes = Base64.decode(media.preview, Base64.DEFAULT) - BitmapFactory.decodeByteArray(bytes, 0, bytes.size) - }.getOrNull() - } else null - } - val isLoaded = resolvedBitmap != null || model != null - // Animate alpha for smooth fade-in - val imageAlpha by animateFloatAsState( - targetValue = if (isLoaded) 1f else 0f, - animationSpec = tween(300), - label = "media_fade" - ) - - Box( - modifier = Modifier - .size(mediaCellSize) - .clip(RoundedCornerShape(0.dp)) - .clickable( - enabled = model != null || resolvedBitmap != null || media.attachmentId.isNotBlank() - ) { - imageViewerInitialIndex = globalIndex - showImageViewer = true - }, - contentAlignment = Alignment.Center - ) { - // Blurred preview placeholder (always shown initially) - if (previewBitmap != null) { - Image( - bitmap = previewBitmap.asImageBitmap(), - contentDescription = null, - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop - ) - } else { - // Fallback shimmer if no preview - Box( + Column(modifier = Modifier.fillMaxWidth()) { + sharedContent.files.forEachIndexed { index, file -> + Column { + Row( modifier = Modifier - .fillMaxSize() - .background( - if (isDarkTheme) Color(0xFF1E1E1E) - else Color(0xFFECECEC) - ) - ) - } - // Full quality image fades in on top - if (resolvedBitmap != null) { - Image( - bitmap = resolvedBitmap.asImageBitmap(), - contentDescription = "Shared media", - modifier = Modifier.fillMaxSize().graphicsLayer { alpha = imageAlpha }, - contentScale = ContentScale.Crop - ) - } else if (model != null) { - coil.compose.AsyncImage( - model = model, - contentDescription = "Shared media", - modifier = Modifier.fillMaxSize().graphicsLayer { alpha = imageAlpha }, - contentScale = ContentScale.Crop - ) + .fillMaxWidth() + .clickable { + val opened = openSharedFile(context, file) + if (!opened) { + Toast.makeText(context, "File is not available on this device", Toast.LENGTH_SHORT).show() + } + } + .padding(horizontal = 20.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier.size(36.dp).clip(CircleShape) + .background(PrimaryBlue.copy(alpha = if (isDarkTheme) 0.25f else 0.12f)), + contentAlignment = Alignment.Center + ) { + Icon(painter = TelegramIcons.File, contentDescription = null, tint = PrimaryBlue, modifier = Modifier.size(18.dp)) + } + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text(text = file.fileName, color = fileTextColor, fontSize = 15.sp, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis) + Spacer(modifier = Modifier.height(2.dp)) + Text(text = "${formatFileSize(file.sizeBytes)} • ${formatTimestamp(file.timestamp)}", color = fileSecondary, fontSize = 12.sp) + } + Spacer(modifier = Modifier.width(8.dp)) + Icon(imageVector = TablerIcons.ChevronRight, contentDescription = null, tint = fileSecondary.copy(alpha = 0.6f), modifier = Modifier.size(16.dp)) + } + if (index != sharedContent.files.lastIndex) { + Divider(color = fileDivider, thickness = 0.5.dp) + } } } } - // Fill remaining cells in last incomplete row - repeat(mediaColumns - rowPhotos.size) { - Spacer(modifier = Modifier.size(mediaCellSize)) - } } } - } - } - OtherProfileTab.FILES -> { - if (sharedContent.files.isEmpty()) { - item(key = "files_empty") { - OtherProfileEmptyState( - animationAssetPath = "lottie/folder.json", - title = "No shared files", - subtitle = "Documents from this chat will appear here.", - isDarkTheme = isDarkTheme - ) - } - } else { - val fileTextColor = if (isDarkTheme) Color.White else Color.Black - val fileSecondary = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) - val fileDivider = if (isDarkTheme) Color(0xFF333333) else Color(0xFFD9DDE5) + // ── LINKS ── + 2 -> { + if (sharedContent.links.isEmpty()) { + OtherProfileEmptyState( + animationAssetPath = "lottie/earth.json", + title = "No shared links", + subtitle = "Links from your messages will appear here.", + isDarkTheme = isDarkTheme + ) + } else { + val linkSecondary = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) + val linkDivider = if (isDarkTheme) Color(0xFF333333) else Color(0xFFD9DDE5) - itemsIndexed(sharedContent.files, key = { _, f -> f.key }) { index, file -> - Column { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { - val opened = openSharedFile(context, file) - if (!opened) { - Toast.makeText(context, "File is not available on this device", Toast.LENGTH_SHORT).show() - } - } - .padding(horizontal = 20.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Box( - modifier = Modifier.size(36.dp).clip(CircleShape) - .background(PrimaryBlue.copy(alpha = if (isDarkTheme) 0.25f else 0.12f)), - contentAlignment = Alignment.Center - ) { - Icon(painter = TelegramIcons.File, contentDescription = null, tint = PrimaryBlue, modifier = Modifier.size(18.dp)) + Column(modifier = Modifier.fillMaxWidth()) { + sharedContent.links.forEachIndexed { index, link -> + Column { + Column( + modifier = Modifier + .fillMaxWidth() + .clickable { + val normalizedLink = if (link.url.startsWith("http://", ignoreCase = true) || link.url.startsWith("https://", ignoreCase = true)) link.url else "https://${link.url}" + val opened = runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(normalizedLink))) }.isSuccess + if (!opened) { + Toast.makeText(context, "Unable to open this link", Toast.LENGTH_SHORT).show() + } + } + .padding(horizontal = 20.dp, vertical = 12.dp) + ) { + Text(text = link.url, color = PrimaryBlue, fontSize = 15.sp, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis, textDecoration = TextDecoration.Underline) + Spacer(modifier = Modifier.height(3.dp)) + Text(text = formatTimestamp(link.timestamp), color = linkSecondary, fontSize = 12.sp) + } + if (index != sharedContent.links.lastIndex) { + Divider(color = linkDivider, thickness = 0.5.dp) + } + } } - Spacer(modifier = Modifier.width(12.dp)) - Column(modifier = Modifier.weight(1f)) { - Text(text = file.fileName, color = fileTextColor, fontSize = 15.sp, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis) - Spacer(modifier = Modifier.height(2.dp)) - Text(text = "${formatFileSize(file.sizeBytes)} • ${formatTimestamp(file.timestamp)}", color = fileSecondary, fontSize = 12.sp) - } - Spacer(modifier = Modifier.width(8.dp)) - Icon(imageVector = TablerIcons.ChevronRight, contentDescription = null, tint = fileSecondary.copy(alpha = 0.6f), modifier = Modifier.size(16.dp)) - } - if (index != sharedContent.files.lastIndex) { - Divider(color = fileDivider, thickness = 0.5.dp) - } - } - } - } - } - OtherProfileTab.LINKS -> { - if (sharedContent.links.isEmpty()) { - item(key = "links_empty") { - OtherProfileEmptyState( - animationAssetPath = "lottie/earth.json", - title = "No shared links", - subtitle = "Links from your messages will appear here.", - isDarkTheme = isDarkTheme - ) - } - } else { - val linkSecondary = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) - val linkDivider = if (isDarkTheme) Color(0xFF333333) else Color(0xFFD9DDE5) - - itemsIndexed(sharedContent.links, key = { _, l -> l.key }) { index, link -> - Column { - Column( - modifier = Modifier - .fillMaxWidth() - .clickable { - val normalizedLink = if (link.url.startsWith("http://", ignoreCase = true) || link.url.startsWith("https://", ignoreCase = true)) link.url else "https://${link.url}" - val opened = runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(normalizedLink))) }.isSuccess - if (!opened) { - Toast.makeText(context, "Unable to open this link", Toast.LENGTH_SHORT).show() - } - } - .padding(horizontal = 20.dp, vertical = 12.dp) - ) { - Text(text = link.url, color = PrimaryBlue, fontSize = 15.sp, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis, textDecoration = TextDecoration.Underline) - Spacer(modifier = Modifier.height(3.dp)) - Text(text = formatTimestamp(link.timestamp), color = linkSecondary, fontSize = 12.sp) - } - if (index != sharedContent.links.lastIndex) { - Divider(color = linkDivider, thickness = 0.5.dp) } } }