Compare commits

...

2 Commits

3 changed files with 231 additions and 202 deletions

View File

@@ -895,7 +895,8 @@ fun MainScreen(
isVisible = isThemeVisible, isVisible = isThemeVisible,
onBack = { navStack = navStack.filterNot { it is Screen.Theme } }, onBack = { navStack = navStack.filterNot { it is Screen.Theme } },
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
layer = 2 layer = 2,
deferToChildren = true
) { ) {
ThemeScreen( ThemeScreen(
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,

View File

@@ -88,6 +88,7 @@ fun SwipeBackContainer(
layer: Int = 1, layer: Int = 1,
swipeEnabled: Boolean = true, swipeEnabled: Boolean = true,
propagateBackgroundProgress: Boolean = true, propagateBackgroundProgress: Boolean = true,
deferToChildren: Boolean = false,
content: @Composable () -> Unit content: @Composable () -> Unit
) { ) {
// 🚀 Lazy composition: skip ALL setup until the screen is opened for the first time. // 🚀 Lazy composition: skip ALL setup until the screen is opened for the first time.
@@ -243,7 +244,7 @@ fun SwipeBackContainer(
alpha = currentAlpha alpha = currentAlpha
} }
.background(if (isDarkTheme) Color(0xFF1B1B1B) else Color.White) .background(if (isDarkTheme) Color(0xFF1B1B1B) else Color.White)
.pointerInput(swipeEnabled, isAnimatingIn, isAnimatingOut) { .pointerInput(swipeEnabled, isAnimatingIn, isAnimatingOut, deferToChildren) {
if (!swipeEnabled || isAnimatingIn || isAnimatingOut) return@pointerInput if (!swipeEnabled || isAnimatingIn || isAnimatingOut) return@pointerInput
val velocityTracker = VelocityTracker() val velocityTracker = VelocityTracker()
@@ -268,12 +269,14 @@ fun SwipeBackContainer(
var totalDragY = 0f var totalDragY = 0f
var passedSlop = false var passedSlop = false
// Pre-slop: use Main pass so children (e.g. LazyRow) // deferToChildren=true: pre-slop uses Main pass so children
// process first — if they consume, we back off. // (e.g. LazyRow) process first — if they consume, we back off.
// Post-claim: use Initial pass to intercept before children. // deferToChildren=false (default): always use Initial pass
// to intercept before children (original behavior).
// Post-claim: always Initial to block children.
while (true) { while (true) {
val pass = val pass =
if (startedSwipe) if (startedSwipe || !deferToChildren)
PointerEventPass.Initial PointerEventPass.Initial
else PointerEventPass.Main else PointerEventPass.Main
val event = awaitPointerEvent(pass) val event = awaitPointerEvent(pass)

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,9 +186,30 @@ 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 + control swipe-back
LaunchedEffect(pagerState) {
snapshotFlow { pagerState.currentPage }.collect { page ->
selectedTab = OtherProfileTab.entries[page]
// Swipe-back only on first tab (Media); on other tabs pager handles swipe
onSwipeBackEnabledChanged(page == 0 && !showImageViewer)
}
}
LaunchedEffect(showImageViewer) { LaunchedEffect(showImageViewer) {
onSwipeBackEnabledChanged(!showImageViewer) onSwipeBackEnabledChanged(!showImageViewer && pagerState.currentPage == 0)
} }
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF) val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF)
@@ -711,25 +735,29 @@ 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(
state = pagerState,
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.Top
) { page ->
when (page) {
// ── MEDIA ──
0 -> {
if (sharedContent.mediaPhotos.isEmpty()) { if (sharedContent.mediaPhotos.isEmpty()) {
item(key = "media_empty") {
OtherProfileEmptyState( OtherProfileEmptyState(
animationAssetPath = "lottie/saved.json", animationAssetPath = "lottie/saved.json",
title = "No shared media yet", title = "No shared media yet",
subtitle = "Photos from your chat will appear here.", subtitle = "Photos from your chat will appear here.",
isDarkTheme = isDarkTheme isDarkTheme = isDarkTheme
) )
}
} else { } else {
items( Column(modifier = Modifier.fillMaxWidth()) {
items = mediaIndexedRows, mediaIndexedRows.forEach { (rowIdx, rowPhotos) ->
key = { (idx, _) -> "media_row_$idx" }
) { (rowIdx, rowPhotos) ->
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(mediaSpacing) horizontalArrangement = Arrangement.spacedBy(mediaSpacing)
@@ -737,11 +765,9 @@ fun OtherProfileScreen(
rowPhotos.forEachIndexed { colIdx, media -> rowPhotos.forEachIndexed { colIdx, media ->
val globalIndex = rowIdx * mediaColumns + colIdx 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 {
@@ -762,7 +788,6 @@ fun OtherProfileScreen(
val model = remember(media.localUri, media.blob) { val model = remember(media.localUri, media.blob) {
resolveSharedMediaModel(media.localUri, media.blob) resolveSharedMediaModel(media.localUri, media.blob)
} }
// Decode blurred preview from base64 (small image ~4-16px)
val previewBitmap = remember(media.preview) { val previewBitmap = remember(media.preview) {
if (media.preview.isNotBlank()) { if (media.preview.isNotBlank()) {
runCatching { runCatching {
@@ -772,7 +797,6 @@ fun OtherProfileScreen(
} else null } else null
} }
val isLoaded = resolvedBitmap != null || model != null val isLoaded = resolvedBitmap != null || model != null
// Animate alpha for smooth fade-in
val imageAlpha by animateFloatAsState( val imageAlpha by animateFloatAsState(
targetValue = if (isLoaded) 1f else 0f, targetValue = if (isLoaded) 1f else 0f,
animationSpec = tween(300), animationSpec = tween(300),
@@ -791,7 +815,6 @@ fun OtherProfileScreen(
}, },
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
// Blurred preview placeholder (always shown initially)
if (previewBitmap != null) { if (previewBitmap != null) {
Image( Image(
bitmap = previewBitmap.asImageBitmap(), bitmap = previewBitmap.asImageBitmap(),
@@ -800,7 +823,6 @@ fun OtherProfileScreen(
contentScale = ContentScale.Crop contentScale = ContentScale.Crop
) )
} else { } else {
// Fallback shimmer if no preview
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -810,7 +832,6 @@ fun OtherProfileScreen(
) )
) )
} }
// Full quality image fades in on top
if (resolvedBitmap != null) { if (resolvedBitmap != null) {
Image( Image(
bitmap = resolvedBitmap.asImageBitmap(), bitmap = resolvedBitmap.asImageBitmap(),
@@ -828,7 +849,6 @@ fun OtherProfileScreen(
} }
} }
} }
// Fill remaining cells in last incomplete row
repeat(mediaColumns - rowPhotos.size) { repeat(mediaColumns - rowPhotos.size) {
Spacer(modifier = Modifier.size(mediaCellSize)) Spacer(modifier = Modifier.size(mediaCellSize))
} }
@@ -836,22 +856,23 @@ fun OtherProfileScreen(
} }
} }
} }
OtherProfileTab.FILES -> { }
// ── FILES ──
1 -> {
if (sharedContent.files.isEmpty()) { if (sharedContent.files.isEmpty()) {
item(key = "files_empty") {
OtherProfileEmptyState( OtherProfileEmptyState(
animationAssetPath = "lottie/folder.json", animationAssetPath = "lottie/folder.json",
title = "No shared files", title = "No shared files",
subtitle = "Documents from this chat will appear here.", subtitle = "Documents from this chat will appear here.",
isDarkTheme = isDarkTheme isDarkTheme = isDarkTheme
) )
}
} else { } else {
val fileTextColor = if (isDarkTheme) Color.White else Color.Black val fileTextColor = if (isDarkTheme) Color.White else Color.Black
val fileSecondary = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) val fileSecondary = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
val fileDivider = if (isDarkTheme) Color(0xFF333333) else Color(0xFFD9DDE5) 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 { Column {
Row( Row(
modifier = Modifier modifier = Modifier
@@ -888,21 +909,22 @@ fun OtherProfileScreen(
} }
} }
} }
OtherProfileTab.LINKS -> { }
// ── LINKS ──
2 -> {
if (sharedContent.links.isEmpty()) { if (sharedContent.links.isEmpty()) {
item(key = "links_empty") {
OtherProfileEmptyState( OtherProfileEmptyState(
animationAssetPath = "lottie/earth.json", animationAssetPath = "lottie/earth.json",
title = "No shared links", title = "No shared links",
subtitle = "Links from your messages will appear here.", subtitle = "Links from your messages will appear here.",
isDarkTheme = isDarkTheme isDarkTheme = isDarkTheme
) )
}
} else { } else {
val linkSecondary = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) val linkSecondary = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
val linkDivider = if (isDarkTheme) Color(0xFF333333) else Color(0xFFD9DDE5) 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 {
Column( Column(
modifier = Modifier modifier = Modifier
@@ -928,6 +950,9 @@ fun OtherProfileScreen(
} }
} }
} }
}
}
}
item(key = "bottom_spacer") { item(key = "bottom_spacer") {
Spacer(modifier = Modifier.height(32.dp)) Spacer(modifier = Modifier.height(32.dp))