diff --git a/core/ui/src/main/java/org/signal/core/ui/compose/Chrome.kt b/core/ui/src/main/java/org/signal/core/ui/compose/Chrome.kt new file mode 100644 index 0000000000..7a1fd4ab9d --- /dev/null +++ b/core/ui/src/main/java/org/signal/core/ui/compose/Chrome.kt @@ -0,0 +1,47 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.core.ui.compose + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha + +/** The controls laid over a full-bleed surface, such as a camera preview or a photo being edited. */ +object Chrome { + + /** + * A control on such a surface, and the two ways it gets out of the user's way. Going not-[visible] gives up its + * layout space, letting the rest of the stack settle into it, while [faded] holds onto the space -- releasing it + * mid-gesture would move whatever the user is dragging out from under their finger. + */ + @Composable + fun Control( + modifier: Modifier = Modifier, + visible: Boolean = true, + faded: Boolean = false, + enter: EnterTransition = fadeIn(), + exit: ExitTransition = fadeOut(), + content: @Composable () -> Unit + ) { + val alpha by animateFloatAsState(targetValue = if (faded) 0f else 1f, label = "ChromeControlAlpha") + + AnimatedVisibility( + visible = visible, + enter = enter, + exit = exit, + modifier = modifier.alpha(alpha) + ) { + content() + } + } +} diff --git a/feature/camera/src/main/java/org/signal/camera/hud/StandardCameraHud.kt b/feature/camera/src/main/java/org/signal/camera/hud/StandardCameraHud.kt index c43726e317..4cebdebcba 100644 --- a/feature/camera/src/main/java/org/signal/camera/hud/StandardCameraHud.kt +++ b/feature/camera/src/main/java/org/signal/camera/hud/StandardCameraHud.kt @@ -74,6 +74,7 @@ import org.signal.camera.R import org.signal.camera.test.TestTags import org.signal.core.ui.WindowBreakpoint import org.signal.core.ui.compose.AllNightPreviews +import org.signal.core.ui.compose.Chrome import org.signal.core.ui.compose.SignalIcons import org.signal.core.ui.rememberWindowBreakpoint import java.util.Locale @@ -93,7 +94,7 @@ private val ZOOM_BAR_SIDE_MARGIN = 16.dp /** How long the time and the paused label take to trade the recording red between them. */ private const val PAUSED_TRANSITION_MS = 200 -/** The close and flash buttons along the top of the window, which the recording pill lines its middle up with. */ +/** The close and flash buttons along the top of the window, whose middle the recording pill lines itself up with. */ private val TOP_CONTROL_SIZE = 48.dp /** How far the top controls sit in from the edges of the window. */ @@ -285,38 +286,44 @@ private fun BoxScope.StandardCameraHudContent( ShutterOverlay(state.showShutter) - IconButton( - onClick = { emitter(StandardCameraHudEvents.CloseClick) }, - enabled = !isRecordingHeld, - modifier = modifier - .padding(TOP_CONTROL_MARGIN) - .size(TOP_CONTROL_SIZE) - .fadedIn(!isRecordingHeld) - .background(colorResource(R.color.CameraHud_control_background), shape = CircleShape) - .testTag(TestTags.CAMERA_HUD_CLOSE_BUTTON) + Chrome.Control( + faded = isRecordingHeld, + modifier = modifier.padding(TOP_CONTROL_MARGIN) ) { - Icon( - imageVector = SignalIcons.X.imageVector, - contentDescription = null, - tint = colorResource(R.color.CameraHud_control_foreground), + IconButton( + onClick = { emitter(StandardCameraHudEvents.CloseClick) }, + enabled = !isRecordingHeld, modifier = Modifier - .size(24.dp) - .rotate(iconRotation) - ) + .size(TOP_CONTROL_SIZE) + .background(colorResource(R.color.CameraHud_control_background), shape = CircleShape) + .testTag(TestTags.CAMERA_HUD_CLOSE_BUTTON) + ) { + Icon( + imageVector = SignalIcons.X.imageVector, + contentDescription = null, + tint = colorResource(R.color.CameraHud_control_foreground), + modifier = Modifier + .size(24.dp) + .rotate(iconRotation) + ) + } } if (isPortraitPhone) { - FlashToggleButton( - flashMode = state.flashMode, - onToggle = { emitter(StandardCameraHudEvents.ToggleFlash) }, - stringResources = stringResources, - enabled = !isRecordingHeld, + // The flash belongs to a photo the camera has yet to take, so the button goes for as long as a recording runs. + Chrome.Control( + visible = !state.isRecording, modifier = Modifier .align(Alignment.TopEnd) .padding(TOP_CONTROL_MARGIN) - .fadedIn(!isRecordingHeld) - .rotate(iconRotation) - ) + ) { + FlashToggleButton( + flashMode = state.flashMode, + onToggle = { emitter(StandardCameraHudEvents.ToggleFlash) }, + stringResources = stringResources, + modifier = Modifier.rotate(iconRotation) + ) + } } if (state.isRecording) { @@ -397,17 +404,6 @@ private fun Offset.rotatedBy(degrees: Float): Offset { return Offset(x = x * cosine - y * sine, y = x * sine + y * cosine) } -/** - * Fades a piece of chrome in or out in place. It keeps its space in the layout while it is gone, so what is around it - * cannot move out from under a finger that is midway through a gesture. - */ -@Composable -private fun Modifier.fadedIn(visible: Boolean): Modifier { - val chromeAlpha by animateFloatAsState(targetValue = if (visible) 1f else 0f, label = "HudChromeAlpha") - - return graphicsLayer { alpha = chromeAlpha } -} - /** Degrees to rotate a HUD icon so it stays upright at the given committed [Surface] rotation. */ private fun uprightRotationDegrees(surfaceRotation: Int): Float = when (surfaceRotation) { Surface.ROTATION_90 -> 90f @@ -657,10 +653,8 @@ private fun BoxScope.CameraSwitchCorner( stringResources: StringResources, emitter: (StandardCameraHudEvents) -> Unit ) { - AnimatedVisibility( + Chrome.Control( visible = !isRecording, - enter = fadeIn(), - exit = fadeOut(), modifier = Modifier.align(Alignment.CenterEnd) ) { Box(modifier = Modifier.rotate(iconRotation)) { @@ -695,14 +689,14 @@ private fun VerticalControlBar( .weight(1f) .padding(bottom = 40.dp) ) { - FlashAndCameraTogglePill( - flashMode = flashMode, - emitter = emitter, - stringResources = stringResources, - isRecording = captureButtonState.isRecording, - enabled = captureButtonState != CaptureButtonState.RECORDING_HELD, - modifier = Modifier.fadedIn(captureButtonState != CaptureButtonState.RECORDING_HELD) - ) + // Neither the flash nor the camera switch has anything to offer a running recording, so the pill goes whole. + Chrome.Control(visible = !captureButtonState.isRecording) { + FlashAndCameraTogglePill( + flashMode = flashMode, + emitter = emitter, + stringResources = stringResources + ) + } } captureSlot(captureButtonState) @@ -723,8 +717,6 @@ private fun FlashAndCameraTogglePill( flashMode: FlashMode, stringResources: StringResources, emitter: (StandardCameraHudEvents) -> Unit, - isRecording: Boolean, - enabled: Boolean = true, modifier: Modifier = Modifier ) { Column( @@ -735,7 +727,6 @@ private fun FlashAndCameraTogglePill( ) { IconButton( onClick = { emitter(StandardCameraHudEvents.ToggleFlash) }, - enabled = enabled, modifier = Modifier.testTag(TestTags.CAMERA_HUD_FLASH_BUTTON) ) { FlashToggleButtonIcon( @@ -744,20 +735,15 @@ private fun FlashAndCameraTogglePill( ) } - // The camera cannot be swapped out from under a running recording, so the pill gives the button up and closes - // around the flash for as long as one runs. - AnimatedVisibility(visible = !isRecording) { - IconButton( - onClick = { emitter(StandardCameraHudEvents.SwitchCamera) }, - enabled = enabled, - modifier = Modifier.testTag(TestTags.CAMERA_HUD_SWITCH_BUTTON) - ) { - Icon( - imageVector = SignalIcons.CameraSwitch.imageVector, - contentDescription = if (stringResources.switchCamera != 0) stringResource(stringResources.switchCamera) else null, - tint = colorResource(R.color.CameraHud_control_foreground) - ) - } + IconButton( + onClick = { emitter(StandardCameraHudEvents.SwitchCamera) }, + modifier = Modifier.testTag(TestTags.CAMERA_HUD_SWITCH_BUTTON) + ) { + Icon( + imageVector = SignalIcons.CameraSwitch.imageVector, + contentDescription = if (stringResources.switchCamera != 0) stringResource(stringResources.switchCamera) else null, + tint = colorResource(R.color.CameraHud_control_foreground) + ) } } } @@ -865,12 +851,10 @@ private fun FlashToggleButton( flashMode: FlashMode, onToggle: () -> Unit, stringResources: StringResources, - enabled: Boolean = true, modifier: Modifier = Modifier ) { IconButton( onClick = onToggle, - enabled = enabled, modifier = modifier .size(TOP_CONTROL_SIZE) .background(colorResource(R.color.CameraHud_control_background), shape = CircleShape) diff --git a/feature/camera/src/test/java/org/signal/camera/hud/StandardCameraHudTest.kt b/feature/camera/src/test/java/org/signal/camera/hud/StandardCameraHudTest.kt index af2664a5be..0cae76ab12 100644 --- a/feature/camera/src/test/java/org/signal/camera/hud/StandardCameraHudTest.kt +++ b/feature/camera/src/test/java/org/signal/camera/hud/StandardCameraHudTest.kt @@ -90,16 +90,29 @@ class StandardCameraHudTest { setContent(state = heldRecording()) composeTestRule.onNodeWithTag(TestTags.CAMERA_HUD_CLOSE_BUTTON).assertIsNotEnabled() - composeTestRule.onNodeWithTag(TestTags.CAMERA_HUD_FLASH_BUTTON).assertIsNotEnabled() } - /** A locked recording leaves the hand free, so nothing but the camera switch has to be taken away. */ + /** A locked recording leaves the hand free, so nothing but the flash and the camera switch has to be taken away. */ @Test fun `Given a recording that is locked, when displayed, then the chrome around it can still be used`() { setContent(state = lockedRecording()) composeTestRule.onNodeWithTag(TestTags.CAMERA_HUD_CLOSE_BUTTON).assertIsEnabled() - composeTestRule.onNodeWithTag(TestTags.CAMERA_HUD_FLASH_BUTTON).assertIsEnabled() + } + + /** The flash is for a photo the camera is not going to take, however the recording was started. */ + @Test + fun `Given a recording, when displayed, then the flash is gone`() { + setContent(state = lockedRecording()) + + composeTestRule.onNodeWithTag(TestTags.CAMERA_HUD_FLASH_BUTTON).assertDoesNotExist() + } + + @Test + fun `Given a recording that is being held, when displayed, then the flash is gone`() { + setContent(state = heldRecording()) + + composeTestRule.onNodeWithTag(TestTags.CAMERA_HUD_FLASH_BUTTON).assertDoesNotExist() } /** The camera cannot be swapped out from under a running recording, however that recording was started. */ @@ -259,7 +272,6 @@ class StandardCameraHudTest { setContent(state = heldRecording()) composeTestRule.onNodeWithTag(TestTags.CAMERA_HUD_CLOSE_BUTTON).performClick() - composeTestRule.onNodeWithTag(TestTags.CAMERA_HUD_FLASH_BUTTON).performClick() assertThat(events).isEmpty() } @@ -339,20 +351,22 @@ class StandardCameraHudTest { assertThat(events).containsExactly(StandardCameraHudEvents.ToggleFlash, StandardCameraHudEvents.SwitchCamera) } + /** Neither of the two has anything to offer a running recording, so the pill goes rather than empties. */ @Test @Config(qualifiers = "w840dp-h1000dp") - fun `Given a window too large for the bottom bar, when a recording is held, then the pill cannot be used`() { - setContent(state = heldRecording()) - - composeTestRule.onNodeWithTag(TestTags.CAMERA_HUD_FLASH_BUTTON).assertIsNotEnabled() - } - - /** The pill gives the switch up and closes around the flash, the same as the bottom bar drops it. */ - @Test - @Config(qualifiers = "w840dp-h1000dp") - fun `Given a window too large for the bottom bar, when a recording runs, then the pill has no camera switch`() { + fun `Given a window too large for the bottom bar, when a recording runs, then the pill is gone`() { setContent(state = lockedRecording()) + composeTestRule.onNodeWithTag(TestTags.CAMERA_HUD_FLASH_BUTTON).assertDoesNotExist() + composeTestRule.onNodeWithTag(TestTags.CAMERA_HUD_SWITCH_BUTTON).assertDoesNotExist() + } + + @Test + @Config(qualifiers = "w840dp-h1000dp") + fun `Given a window too large for the bottom bar, when a recording is held, then the pill is gone`() { + setContent(state = heldRecording()) + + composeTestRule.onNodeWithTag(TestTags.CAMERA_HUD_FLASH_BUTTON).assertDoesNotExist() composeTestRule.onNodeWithTag(TestTags.CAMERA_HUD_SWITCH_BUTTON).assertDoesNotExist() } diff --git a/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/MediaEditControl.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/MediaEditControl.kt index 170286f128..0ae148120c 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/MediaEditControl.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/MediaEditControl.kt @@ -5,21 +5,14 @@ package org.signal.mediasend.screens.edit -import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition -import androidx.compose.animation.core.animateFloatAsState import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha +import org.signal.core.ui.compose.Chrome import org.signal.mediasend.screens.MediaSendMetrics -/** - * A control on the edit screen, and the two ways it gets out of the user's way. Going not-[visible] gives up its layout - * space, letting the rest of the stack settle into it, while [faded] holds onto the space -- releasing it mid-gesture - * would move whatever the user is dragging out from under their finger. - */ +/** A [Chrome.Control] on the edit screen, carrying the transitions the rest of the screen animates with. */ @Composable internal fun MediaEditControl( faded: Boolean, @@ -29,14 +22,12 @@ internal fun MediaEditControl( exit: ExitTransition = MediaSendMetrics.ControlExitTransition, content: @Composable () -> Unit ) { - val alpha by animateFloatAsState(targetValue = if (faded) 0f else 1f) - - AnimatedVisibility( + Chrome.Control( + modifier = modifier, visible = visible, + faded = faded, enter = enter, exit = exit, - modifier = modifier.alpha(alpha) - ) { - content() - } + content = content + ) }