mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-09-20 00:35:47 +01:00
Hide flash toggle when recording video.
This commit is contained in:
committed by
Cody Henthorne
parent
533fcdc604
commit
406be67a6f
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
+7
-16
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user