mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-09-20 00:35:47 +01:00
Add paused info bar.
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user