feat: implement HorizontalPager for tab navigation in OtherProfileScreen
This commit is contained in:
@@ -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,25 +733,29 @@ 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 -> {
|
||||
item(key = "tab_pager") {
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.Top
|
||||
) { page ->
|
||||
when (page) {
|
||||
// ── MEDIA ──
|
||||
0 -> {
|
||||
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) ->
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
mediaIndexedRows.forEach { (rowIdx, rowPhotos) ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(mediaSpacing)
|
||||
@@ -737,11 +763,9 @@ fun OtherProfileScreen(
|
||||
rowPhotos.forEachIndexed { colIdx, media ->
|
||||
val globalIndex = rowIdx * mediaColumns + colIdx
|
||||
|
||||
// Check cache first
|
||||
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 {
|
||||
@@ -762,7 +786,6 @@ fun OtherProfileScreen(
|
||||
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 {
|
||||
@@ -772,7 +795,6 @@ fun OtherProfileScreen(
|
||||
} 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),
|
||||
@@ -791,7 +813,6 @@ fun OtherProfileScreen(
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
// Blurred preview placeholder (always shown initially)
|
||||
if (previewBitmap != null) {
|
||||
Image(
|
||||
bitmap = previewBitmap.asImageBitmap(),
|
||||
@@ -800,7 +821,6 @@ fun OtherProfileScreen(
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
} else {
|
||||
// Fallback shimmer if no preview
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
@@ -810,7 +830,6 @@ fun OtherProfileScreen(
|
||||
)
|
||||
)
|
||||
}
|
||||
// Full quality image fades in on top
|
||||
if (resolvedBitmap != null) {
|
||||
Image(
|
||||
bitmap = resolvedBitmap.asImageBitmap(),
|
||||
@@ -828,7 +847,6 @@ fun OtherProfileScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fill remaining cells in last incomplete row
|
||||
repeat(mediaColumns - rowPhotos.size) {
|
||||
Spacer(modifier = Modifier.size(mediaCellSize))
|
||||
}
|
||||
@@ -836,22 +854,23 @@ fun OtherProfileScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
OtherProfileTab.FILES -> {
|
||||
}
|
||||
// ── FILES ──
|
||||
1 -> {
|
||||
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)
|
||||
|
||||
itemsIndexed(sharedContent.files, key = { _, f -> f.key }) { index, file ->
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
sharedContent.files.forEachIndexed { index, file ->
|
||||
Column {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
@@ -888,21 +907,22 @@ fun OtherProfileScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
OtherProfileTab.LINKS -> {
|
||||
}
|
||||
// ── LINKS ──
|
||||
2 -> {
|
||||
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(modifier = Modifier.fillMaxWidth()) {
|
||||
sharedContent.links.forEachIndexed { index, link ->
|
||||
Column {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@@ -928,6 +948,9 @@ fun OtherProfileScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item(key = "bottom_spacer") {
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
Reference in New Issue
Block a user