Add paused info bar.

This commit is contained in:
Alex Hart
2026-08-26 15:22:21 -04:00
committed by Michelle Tang
parent 9265229d2e
commit 685845ec8e
5 changed files with 118 additions and 14 deletions
@@ -18,6 +18,7 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
@@ -50,6 +51,7 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.onGloballyPositioned
@@ -87,6 +89,9 @@ private val ZOOM_BAR_BOTTOM_MARGIN = 16.dp
/** How far the zoom bar sits in from the start edge on anything larger than a phone. */
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
data class StringResources(
@param:StringRes val photoCaptureFailed: Int = 0,
@param:StringRes val photoProcessingFailed: Int = 0,
@@ -94,7 +99,8 @@ data class StringResources(
@param:StringRes val flashOff: Int = 0,
@param:StringRes val flashOn: Int = 0,
@param:StringRes val flashAuto: Int = 0,
@param:StringRes val send: Int = 0
@param:StringRes val send: Int = 0,
@param:StringRes val recordingPaused: Int = 0
)
/**
@@ -309,6 +315,8 @@ private fun BoxScope.StandardCameraHudContent(
if (state.isRecording) {
RecordingDurationDisplay(
durationMillis = state.recordingDuration,
isPaused = state.isRecordingPaused,
pausedLabel = if (stringResources.recordingPaused != 0) stringResource(stringResources.recordingPaused) else null,
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = 16.dp)
@@ -716,27 +724,66 @@ private fun FlashAndCameraTogglePill(
}
}
/**
* The elapsed time, and below it while paused, the caller's label for a paused recording.
*
* @param pausedLabel What to call a paused recording, or null to show the time alone.
*/
@Composable
private fun RecordingDurationDisplay(
durationMillis: Long,
isPaused: Boolean,
pausedLabel: String?,
modifier: Modifier = Modifier
) {
val seconds = (durationMillis / 1000) % 60
val minutes = (durationMillis / 1000) / 60
val timeText = String.format(Locale.US, "%02d:%02d", minutes, seconds)
Box(
val pausedProgress by animateFloatAsState(
targetValue = if (isPaused) 1f else 0f,
animationSpec = tween(PAUSED_TRANSITION_MS),
label = "RecordingPausedProgress"
)
val recordingRed = colorResource(R.color.CameraHud_control_red_background)
val pausedGray = colorResource(R.color.CameraHud_control_background)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier
.background(colorResource(R.color.CameraHud_control_red_background), shape = CircleShape)
.padding(horizontal = 16.dp, vertical = 4.dp)
.testTag(TestTags.CAMERA_HUD_RECORDING_DURATION)
) {
Text(
text = timeText,
color = Color.White,
fontSize = 18.sp,
fontWeight = FontWeight.Medium
)
Box(
modifier = Modifier
.background(lerp(recordingRed, pausedGray, pausedProgress), shape = CircleShape)
.padding(horizontal = 16.dp, vertical = 4.dp)
.testTag(TestTags.CAMERA_HUD_RECORDING_DURATION)
) {
Text(
text = timeText,
color = Color.White,
fontSize = 18.sp,
fontWeight = FontWeight.Medium
)
}
if (pausedLabel != null && pausedProgress > 0f) {
Box(
modifier = Modifier
.padding(top = 8.dp)
.graphicsLayer { alpha = pausedProgress }
.background(recordingRed, shape = CircleShape)
.padding(horizontal = 16.dp, vertical = 4.dp)
.testTag(TestTags.CAMERA_HUD_RECORDING_PAUSED)
) {
Text(
text = pausedLabel,
color = Color.White,
fontSize = 16.sp,
fontWeight = FontWeight.Medium
)
}
}
}
}
@@ -903,6 +950,20 @@ private fun StandardCameraHudLockedRecordingPreview() {
}
}
/** Both pills, which the HUD previews cannot show without a caller to supply the label. */
@Preview(name = "Recording duration", showBackground = true, backgroundColor = 0xFF444444)
@Composable
private fun RecordingDurationDisplayPreview() {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.padding(16.dp)
) {
RecordingDurationDisplay(durationMillis = 12_000L, isPaused = false, pausedLabel = "Paused")
RecordingDurationDisplay(durationMillis = 12_000L, isPaused = true, pausedLabel = "Paused")
}
}
@Preview(name = "Video mode", showBackground = true, backgroundColor = 0xFF444444, widthDp = 360, heightDp = 640)
@Composable
private fun StandardCameraHudVideoModePreview() {
@@ -21,6 +21,7 @@ object TestTags {
const val CAMERA_HUD_SWITCH_BUTTON = "camera_hud_switch_button"
const val CAMERA_HUD_CAPTURE_BUTTON = "camera_hud_capture_button"
const val CAMERA_HUD_RECORDING_DURATION = "camera_hud_recording_duration"
const val CAMERA_HUD_RECORDING_PAUSED = "camera_hud_recording_paused"
const val CAMERA_HUD_ZOOM_BAR = "camera_hud_zoom_bar"
// What the gallery's corner holds, one at a time
@@ -27,6 +27,7 @@ import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
import org.signal.camera.CameraScreenState
import org.signal.camera.test.TestTags
@@ -49,6 +50,9 @@ class StandardCameraHudTest {
private var longPressTimeoutMillis = 0L
private val pausedLabel: String
get() = RuntimeEnvironment.getApplication().getString(PAUSED_LABEL_RES)
//region What holds the corner beside the capture button
@Test
@@ -266,6 +270,32 @@ class StandardCameraHudTest {
composeTestRule.onNodeWithText("01:05").assertIsDisplayed()
}
@Test
fun `Given a recording that is running, when displayed, then nothing says it is paused`() {
setContent(state = lockedRecording(), stringResources = pausedStringResources())
composeTestRule.onNodeWithTag(TestTags.CAMERA_HUD_RECORDING_PAUSED).assertDoesNotExist()
}
@Test
fun `Given a paused recording, when displayed, then the caller's word for it is shown below the time`() {
setContent(state = pausedRecording().copy(recordingDuration = 12_000L), stringResources = pausedStringResources())
composeTestRule.onNodeWithTag(TestTags.CAMERA_HUD_RECORDING_DURATION).assertIsDisplayed()
composeTestRule.onNodeWithTag(TestTags.CAMERA_HUD_RECORDING_PAUSED).assertIsDisplayed()
composeTestRule.onNodeWithText(pausedLabel).assertIsDisplayed()
composeTestRule.onNodeWithText("00:12").assertIsDisplayed()
}
/** A caller that leaves the label out gets the time alone rather than an empty pill under it. */
@Test
fun `Given a paused recording and a caller with no word for it, when displayed, then only the time is shown`() {
setContent(state = pausedRecording())
composeTestRule.onNodeWithTag(TestTags.CAMERA_HUD_RECORDING_DURATION).assertIsDisplayed()
composeTestRule.onNodeWithTag(TestTags.CAMERA_HUD_RECORDING_PAUSED).assertDoesNotExist()
}
//endregion
//region The same controls on a window too large for the bottom bar
@@ -345,10 +375,15 @@ class StandardCameraHudTest {
zoomRange = ZOOM_RANGE
)
private fun pausedRecording() = lockedRecording().copy(isRecordingPaused = true)
private fun pausedStringResources() = StringResources(recordingPaused = PAUSED_LABEL_RES)
private fun setContent(
state: CameraScreenState = CameraScreenState(zoomRange = ZOOM_RANGE),
captureButtonMode: CaptureButtonMode = CaptureButtonMode.PHOTO,
maxRecordingDurationMs: Long = 0L
maxRecordingDurationMs: Long = 0L,
stringResources: StringResources = StringResources()
) {
composeTestRule.setContent {
longPressTimeoutMillis = LocalViewConfiguration.current.longPressTimeoutMillis
@@ -358,7 +393,8 @@ class StandardCameraHudTest {
state = state,
emitter = { events += it },
captureButtonMode = captureButtonMode,
maxRecordingDurationMs = maxRecordingDurationMs
maxRecordingDurationMs = maxRecordingDurationMs,
stringResources = stringResources
)
}
}
@@ -379,5 +415,8 @@ class StandardCameraHudTest {
private val ZOOM_RANGE = 0.5f..10f
private const val MAX_DURATION = 30_000L
/** The camera module carries no strings of its own, so a framework one stands in for the caller's label. */
private val PAUSED_LABEL_RES = android.R.string.ok
}
}
@@ -631,7 +631,8 @@ fun CameraXScreen(
},
stringResources = StringResources(
photoCaptureFailed = R.string.CameraXFragment_photo_capture_failed,
photoProcessingFailed = R.string.CameraXFragment_photo_processing_failed
photoProcessingFailed = R.string.CameraXFragment_photo_processing_failed,
recordingPaused = R.string.CameraXFragment_paused
)
)
}
@@ -183,6 +183,8 @@
<string name="CameraXFragment_flash_auto">Flash auto</string>
<!-- Accessibility label for the send button in media selection -->
<string name="CameraXFragment_send">Send</string>
<!-- Label shown below the elapsed time while a video recording is paused -->
<string name="CameraXFragment_paused">Paused</string>
<!-- Displayed in a permissions dialog when the user has denied access to hardware for image and video capture. -->
<string name="CameraXFragment_signal_needs_the_recording_permissions_to_capture_video">Signal needs microphone permissions to record videos, but they have been denied. Please continue to app settings, select \"Permissions\", and enable \"Microphone\" and \"Camera\".</string>