diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaViewer.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaViewer.kt index 508638ec7..b7382d7a8 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaViewer.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaViewer.kt @@ -4,6 +4,8 @@ import com.getcode.theme.CodeTheme import android.content.Context import android.content.Intent import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.snap import androidx.compose.animation.core.tween import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -28,6 +30,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState @@ -76,7 +80,8 @@ private const val MIN_BACKGROUND_ALPHA = 0.2f * A full-screen photo. * * Pinch zooms from 1× to 4× with the pan held inside the image's edges; a double tap goes to 2.5× - * about the tapped point, or back to fit when already zoomed. A one-finger drag that starts while + * about the tapped point, or back to fit when already zoomed. A single tap hides or shows the + * close and share buttons. A one-finger drag that starts while * the image is at fit pulls it away; when zoomed the same drag pans instead. * * On its own the pull moves the image down, fading the black behind it, and past a fifth of the @@ -123,6 +128,12 @@ fun ChatMediaViewer( val currentOnPull by rememberUpdatedState(onPull) val currentOnPullEnd by rememberUpdatedState(onPullEnd) val touchSlop = LocalViewConfiguration.current.touchSlop + var chromeVisible by remember { mutableStateOf(true) } + val chromeShown by animateFloatAsState( + targetValue = if (chromeVisible) 1f else 0f, + animationSpec = if (reduceMotion) snap() else tween(200), + label = "chrome", + ) val painter = rememberAsyncImagePainter( model = model, @@ -158,6 +169,7 @@ fun ChatMediaViewer( .testTag(VIEWER_IMAGE_TAG) .pointerInput(zoomState, reduceMotion) { detectTapGestures( + onTap = { if (currentInteractive) chromeVisible = !chromeVisible }, onDoubleTap = { tap -> if (currentInteractive) { scope.launch { zoomState.toggle(tap, animated = !reduceMotion) } @@ -291,11 +303,12 @@ fun ChatMediaViewer( } } - val chrome = Modifier.graphicsLayer { alpha = frame?.invoke()?.chromeAlpha ?: 1f } + val chrome = Modifier.graphicsLayer { alpha = (frame?.invoke()?.chromeAlpha ?: 1f) * chromeShown } ViewerButton( icon = Icons.Filled.Close, description = stringResource(R.string.description_chatMediaClose), enabled = interactive, + visible = chromeVisible, tag = VIEWER_CLOSE_TAG, onClick = onDismiss, modifier = chrome.align(Alignment.TopStart), @@ -304,6 +317,7 @@ fun ChatMediaViewer( icon = Icons.Outlined.IosShare, description = stringResource(R.string.description_chatMediaShare), enabled = fullImageLoaded && interactive, + visible = chromeVisible, tag = VIEWER_SHARE_TAG, onClick = onShare, modifier = chrome.align(Alignment.TopEnd), @@ -323,13 +337,15 @@ private fun ViewerButton( icon: androidx.compose.ui.graphics.vector.ImageVector, description: String, enabled: Boolean, + visible: Boolean, tag: String, onClick: () -> Unit, modifier: Modifier = Modifier, ) { IconButton( onClick = onClick, - enabled = enabled, + // Hidden buttons fade out at full tint but stop taking taps straight away. + enabled = enabled && visible, modifier = modifier .statusBarsPadding() .padding(CodeTheme.dimens.staticGrid.x2) diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaZoomState.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaZoomState.kt index e28e61bcb..947f3de1e 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaZoomState.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaZoomState.kt @@ -9,6 +9,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size +import androidx.compose.ui.geometry.isSpecified import kotlin.math.max /** The zoom arithmetic of the viewer, free of Compose state so it can be tested on the JVM. */ @@ -87,6 +88,8 @@ class ChatMediaZoomState { /** One step of a pinch or pan: [zoom] is the factor since the last event, [pan] its movement. */ internal fun transform(focus: Offset, pan: Offset, zoom: Float) { + // The event that lifts the last finger has no centroid; its NaN would poison the offset. + if (!focus.isSpecified) return val newScale = (scale * zoom).coerceIn(ChatMediaZoom.MIN_SCALE, ChatMediaZoom.MAX_SCALE) val moved = ChatMediaZoom.offsetAfterZoom(offset, focus, scale, newScale, pan, container) scale = newScale diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaZoomTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaZoomTest.kt index bb0f7af53..ffdd2c2fd 100644 --- a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaZoomTest.kt +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatMediaZoomTest.kt @@ -49,4 +49,21 @@ class ChatMediaZoomTest { state.transform(Offset(200f, 400f), Offset.Zero, 0.01f) assertEquals(1f, state.scale) } + + @Test + fun transformIgnoresTheLiftEventsMissingCentroid() { + // The event that lifts the last finger has no pointer still down, so calculateCentroid() + // returns Offset.Unspecified (NaN). Applying it made the offset NaN and the photo vanished. + val state = ChatMediaZoomState().apply { + container = this@ChatMediaZoomTest.container + content = Size(400f, 800f) + } + state.transform(Offset(300f, 500f), Offset.Zero, 2f) + val zoomed = state.offset + + state.transform(Offset.Unspecified, Offset.Zero, 1f) + + assertEquals(2f, state.scale) + assertEquals(zoomed, state.offset) + } } diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoMediaUiTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoMediaUiTest.kt index 01ca1007c..ef340b7d8 100644 --- a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoMediaUiTest.kt +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/media/ChatPhotoMediaUiTest.kt @@ -11,6 +11,7 @@ import androidx.compose.ui.test.assert import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.click import androidx.compose.ui.test.doubleClick import androidx.compose.ui.test.hasProgressBarRangeInfo import androidx.compose.ui.test.junit4.createComposeRule @@ -327,5 +328,26 @@ class ChatPhotoMediaUiTest { assertFalse(zoom.isZoomed) } + @Test + fun singleTapTogglesTheButtons() { + rule.setContent { + DesignSystem { + ChatMediaViewer( + model = null, fullImageLoaded = true, onFullImageLoaded = {}, + onShare = {}, onDismiss = {}, reduceMotion = true, + ) + } + } + rule.onNodeWithTag(VIEWER_IMAGE_TAG).performTouchInput { click(center) } + rule.mainClock.advanceTimeBy(1_000) + rule.onNodeWithTag(VIEWER_CLOSE_TAG).assertIsNotEnabled() + rule.onNodeWithTag(VIEWER_SHARE_TAG).assertIsNotEnabled() + + rule.onNodeWithTag(VIEWER_IMAGE_TAG).performTouchInput { click(center) } + rule.mainClock.advanceTimeBy(1_000) + rule.onNodeWithTag(VIEWER_CLOSE_TAG).assertIsEnabled() + rule.onNodeWithTag(VIEWER_SHARE_TAG).assertIsEnabled() + } + // endregion }