feat: implement HorizontalPager for tab navigation in OtherProfileScreen

This commit is contained in:
2026-03-03 19:07:58 +05:00
parent d0fc8f2f1a
commit ddb6207bb5

View File

@@ -19,6 +19,8 @@ import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi 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.BorderStroke
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
@@ -98,6 +100,7 @@ import compose.icons.tablericons.*
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import androidx.compose.runtime.snapshotFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.sync.withPermit
@@ -183,6 +186,25 @@ fun OtherProfileScreen(
var showImageViewer by remember { mutableStateOf(false) } var showImageViewer by remember { mutableStateOf(false) }
var imageViewerInitialIndex by remember { mutableIntStateOf(0) } var imageViewerInitialIndex by remember { mutableIntStateOf(0) }
var selectedTab by remember { mutableStateOf(OtherProfileTab.MEDIA) } 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) { LaunchedEffect(showImageViewer) {
onSwipeBackEnabledChanged(!showImageViewer) onSwipeBackEnabledChanged(!showImageViewer)
@@ -711,217 +733,218 @@ fun OtherProfileScreen(
} }
// ══════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════
// TAB CONTENT — inlined directly into LazyColumn items // TAB CONTENT — HorizontalPager for swipe between tabs
// for true virtualization (only visible items compose) // On first tab (Media) swipe-right triggers swipe-back;
// on other tabs swipe-right goes to the previous tab.
// ══════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════
when (selectedTab) { item(key = "tab_pager") {
OtherProfileTab.MEDIA -> { HorizontalPager(
if (sharedContent.mediaPhotos.isEmpty()) { state = pagerState,
item(key = "media_empty") { modifier = Modifier.fillMaxWidth(),
OtherProfileEmptyState( verticalAlignment = Alignment.Top
animationAssetPath = "lottie/saved.json", ) { page ->
title = "No shared media yet", when (page) {
subtitle = "Photos from your chat will appear here.", // ── MEDIA ──
isDarkTheme = isDarkTheme 0 -> {
) if (sharedContent.mediaPhotos.isEmpty()) {
} OtherProfileEmptyState(
} else { animationAssetPath = "lottie/saved.json",
items( title = "No shared media yet",
items = mediaIndexedRows, subtitle = "Photos from your chat will appear here.",
key = { (idx, _) -> "media_row_$idx" } isDarkTheme = isDarkTheme
) { (rowIdx, rowPhotos) -> )
Row( } else {
modifier = Modifier.fillMaxWidth(), Column(modifier = Modifier.fillMaxWidth()) {
horizontalArrangement = Arrangement.spacedBy(mediaSpacing) mediaIndexedRows.forEach { (rowIdx, rowPhotos) ->
) { Row(
rowPhotos.forEachIndexed { colIdx, media -> modifier = Modifier.fillMaxWidth(),
val globalIndex = rowIdx * mediaColumns + colIdx horizontalArrangement = Arrangement.spacedBy(mediaSpacing)
) {
rowPhotos.forEachIndexed { colIdx, media ->
val globalIndex = rowIdx * mediaColumns + colIdx
// Check cache first val cachedBitmap = mediaBitmapStates[media.key]
val cachedBitmap = mediaBitmapStates[media.key] ?: SharedMediaBitmapCache.get(media.key)
?: SharedMediaBitmapCache.get(media.key)
// Only launch decode for items not yet cached if (cachedBitmap == null && !mediaBitmapStates.containsKey(media.key)) {
if (cachedBitmap == null && !mediaBitmapStates.containsKey(media.key)) { LaunchedEffect(media.key) {
LaunchedEffect(media.key) { mediaDecodeSemaphore.withPermit {
mediaDecodeSemaphore.withPermit { val bitmap = withContext(Dispatchers.IO) {
val bitmap = withContext(Dispatchers.IO) { resolveSharedPhotoBitmap(
resolveSharedPhotoBitmap( context = context,
context = context, media = media,
media = media, accountPublicKey = activeAccountPublicKey,
accountPublicKey = activeAccountPublicKey, accountPrivateKey = activeAccountPrivateKey
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] Column(modifier = Modifier.fillMaxWidth()) {
val model = remember(media.localUri, media.blob) { sharedContent.files.forEachIndexed { index, file ->
resolveSharedMediaModel(media.localUri, media.blob) Column {
} Row(
// 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(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxWidth()
.background( .clickable {
if (isDarkTheme) Color(0xFF1E1E1E) val opened = openSharedFile(context, file)
else Color(0xFFECECEC) if (!opened) {
) Toast.makeText(context, "File is not available on this device", Toast.LENGTH_SHORT).show()
) }
} }
// Full quality image fades in on top .padding(horizontal = 20.dp, vertical = 12.dp),
if (resolvedBitmap != null) { verticalAlignment = Alignment.CenterVertically
Image( ) {
bitmap = resolvedBitmap.asImageBitmap(), Box(
contentDescription = "Shared media", modifier = Modifier.size(36.dp).clip(CircleShape)
modifier = Modifier.fillMaxSize().graphicsLayer { alpha = imageAlpha }, .background(PrimaryBlue.copy(alpha = if (isDarkTheme) 0.25f else 0.12f)),
contentScale = ContentScale.Crop contentAlignment = Alignment.Center
) ) {
} else if (model != null) { Icon(painter = TelegramIcons.File, contentDescription = null, tint = PrimaryBlue, modifier = Modifier.size(18.dp))
coil.compose.AsyncImage( }
model = model, Spacer(modifier = Modifier.width(12.dp))
contentDescription = "Shared media", Column(modifier = Modifier.weight(1f)) {
modifier = Modifier.fillMaxSize().graphicsLayer { alpha = imageAlpha }, Text(text = file.fileName, color = fileTextColor, fontSize = 15.sp, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis)
contentScale = ContentScale.Crop 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))
}
} }
} }
} // ── LINKS ──
} 2 -> {
OtherProfileTab.FILES -> { if (sharedContent.links.isEmpty()) {
if (sharedContent.files.isEmpty()) { OtherProfileEmptyState(
item(key = "files_empty") { animationAssetPath = "lottie/earth.json",
OtherProfileEmptyState( title = "No shared links",
animationAssetPath = "lottie/folder.json", subtitle = "Links from your messages will appear here.",
title = "No shared files", isDarkTheme = isDarkTheme
subtitle = "Documents from this chat will appear here.", )
isDarkTheme = isDarkTheme } else {
) val linkSecondary = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
} val linkDivider = if (isDarkTheme) Color(0xFF333333) else Color(0xFFD9DDE5)
} 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)
itemsIndexed(sharedContent.files, key = { _, f -> f.key }) { index, file -> Column(modifier = Modifier.fillMaxWidth()) {
Column { sharedContent.links.forEachIndexed { index, link ->
Row( Column {
modifier = Modifier Column(
.fillMaxWidth() modifier = Modifier
.clickable { .fillMaxWidth()
val opened = openSharedFile(context, file) .clickable {
if (!opened) { val normalizedLink = if (link.url.startsWith("http://", ignoreCase = true) || link.url.startsWith("https://", ignoreCase = true)) link.url else "https://${link.url}"
Toast.makeText(context, "File is not available on this device", Toast.LENGTH_SHORT).show() 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), }
verticalAlignment = Alignment.CenterVertically }
) { .padding(horizontal = 20.dp, vertical = 12.dp)
Box( ) {
modifier = Modifier.size(36.dp).clip(CircleShape) Text(text = link.url, color = PrimaryBlue, fontSize = 15.sp, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis, textDecoration = TextDecoration.Underline)
.background(PrimaryBlue.copy(alpha = if (isDarkTheme) 0.25f else 0.12f)), Spacer(modifier = Modifier.height(3.dp))
contentAlignment = Alignment.Center Text(text = formatTimestamp(link.timestamp), color = linkSecondary, fontSize = 12.sp)
) { }
Icon(painter = TelegramIcons.File, contentDescription = null, tint = PrimaryBlue, modifier = Modifier.size(18.dp)) 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)
} }
} }
} }