diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a2317d598..96459ea3d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -114,6 +114,7 @@ robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectr truth = { module = "com.google.truth:truth", version.ref = "truth" } material = { group = "com.google.android.material", name = "material", version.ref = "material" } androidx-material3-window-size-klass = { group = "androidx.compose.material3", name = "material3-window-size-class", version.ref = "material3WindowSizeClass" } +androidx-foundation-layout = { module = "androidx.compose.foundation:foundation-layout" } [plugins] android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" } diff --git a/ui/components/capture/build.gradle.kts b/ui/components/capture/build.gradle.kts index d2f9fc5fa..a4dfd1a10 100644 --- a/ui/components/capture/build.gradle.kts +++ b/ui/components/capture/build.gradle.kts @@ -63,6 +63,7 @@ dependencies { // Compose val composeBom = platform(libs.compose.bom) implementation(composeBom) + implementation(libs.androidx.foundation.layout) // Accompanist - Permissions implementation(libs.accompanist.permissions) diff --git a/ui/components/capture/src/androidTest/java/com/google/jetpackcamera/ui/components/capture/CaptureScreenComponentsTest.kt b/ui/components/capture/src/androidTest/java/com/google/jetpackcamera/ui/components/capture/CaptureScreenComponentsTest.kt new file mode 100644 index 000000000..10d14afef --- /dev/null +++ b/ui/components/capture/src/androidTest/java/com/google/jetpackcamera/ui/components/capture/CaptureScreenComponentsTest.kt @@ -0,0 +1,100 @@ +/* + * Copyright (C) 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.jetpackcamera.ui.components.capture + +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.assertContentDescriptionEquals +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.tryPerformAccessibilityChecks +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.jetpackcamera.ui.uistate.capture.ElapsedTimeUiState +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class CaptureScreenComponentsTest { + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun elapsedTimeText_unavailableState_doesNotRender() { + composeTestRule.setContent { + ElapsedTimeText( + modifier = Modifier.testTag(ELAPSED_TIME_TAG), + elapsedTimeUiStateProvider = { ElapsedTimeUiState.Unavailable } + ) + } + composeTestRule.onNodeWithTag(ELAPSED_TIME_TAG).assertDoesNotExist() + composeTestRule.onRoot().tryPerformAccessibilityChecks() + } + + @Test + fun elapsedTimeText_enabledActive_displaysFormattedTime() { + composeTestRule.setContent { + ElapsedTimeText( + modifier = Modifier.testTag(ELAPSED_TIME_TAG), + elapsedTimeUiStateProvider = { ElapsedTimeUiState.Enabled(30_000_000_000L) } // 0:30 + ) + } + composeTestRule.onNodeWithTag(ELAPSED_TIME_TAG).assertTextEquals("0:30") + composeTestRule.onRoot().tryPerformAccessibilityChecks() + } + + @Test + fun elapsedTimeText_enabledActive_setsContentDescription() { + composeTestRule.setContent { + ElapsedTimeText( + modifier = Modifier.testTag(ELAPSED_TIME_TAG), + elapsedTimeUiStateProvider = { ElapsedTimeUiState.Enabled(65_000_000_000L) } // 1:05 + ) + } + composeTestRule.onNodeWithTag(ELAPSED_TIME_TAG) + .assertContentDescriptionEquals("Recording time: 1 minutes and 5 seconds") + } + + @Test + fun elapsedTimeText_enabledPaused_displaysPausedFormattedTime() { + composeTestRule.setContent { + ElapsedTimeText( + modifier = Modifier.testTag(ELAPSED_TIME_TAG), + elapsedTimeUiStateProvider = { + ElapsedTimeUiState.Enabled(30_000_000_000L, isPaused = true) + } // PAUSED 0:30 + ) + } + composeTestRule.onNodeWithTag(ELAPSED_TIME_TAG).assertTextEquals("PAUSED 0:30") + composeTestRule.onRoot().tryPerformAccessibilityChecks() + } + + @Test + fun elapsedTimeText_enabledPaused_setsContentDescription() { + composeTestRule.setContent { + ElapsedTimeText( + modifier = Modifier.testTag(ELAPSED_TIME_TAG), + elapsedTimeUiStateProvider = { + ElapsedTimeUiState.Enabled(65_000_000_000L, isPaused = true) + } // PAUSED 1:05 + ) + } + composeTestRule.onNodeWithTag(ELAPSED_TIME_TAG) + .assertContentDescriptionEquals("Recording paused: 1 minutes and 5 seconds") + } +} diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureScreenComponents.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureScreenComponents.kt index ddb9ec13b..dda6d72a8 100644 --- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureScreenComponents.kt +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureScreenComponents.kt @@ -49,11 +49,15 @@ import androidx.compose.foundation.border import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.rememberTransformableState import androidx.compose.foundation.gestures.transformable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape @@ -65,7 +69,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.LocalContentColor -import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Text @@ -92,12 +96,16 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.round +import androidx.compose.ui.unit.sp import com.google.jetpackcamera.core.camera.VideoRecordingState import com.google.jetpackcamera.model.CaptureMode import com.google.jetpackcamera.model.StabilizationMode @@ -119,6 +127,7 @@ import com.google.jetpackcamera.ui.uistate.capture.FocusMeteringUiState import com.google.jetpackcamera.ui.uistate.capture.StabilizationUiState import com.google.jetpackcamera.ui.uistate.capture.compound.PreviewDisplayUiState import kotlin.time.Duration.Companion.nanoseconds +import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.delay import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged @@ -131,9 +140,10 @@ private val TAP_TO_FOCUS_INDICATOR_SIZE = 56.dp private const val FOCUS_INDICATOR_RESULT_DELAY = 100L /** - * A composable that displays the elapsed time of a video recording in a "MM:SS" format. + * A composable that displays the elapsed time of a video recording formatted as minutes and seconds. * This text is only visible during an active recording. * + * @param modifier the modifier for this component. * @param elapsedTimeUiStateProvider the provider for [ElapsedTimeUiState] for this component. */ @Composable @@ -143,15 +153,46 @@ fun ElapsedTimeText( ) { val state = elapsedTimeUiStateProvider() if (state is ElapsedTimeUiState.Enabled) { - Text( - modifier = modifier, - text = state.elapsedTimeNanos.nanoseconds - .toComponents { minutes, seconds, _ -> "%02d:%02d".format(minutes, seconds) }, - textAlign = TextAlign.Center, - style = LocalTextStyle.current.copy( - fontFeatureSettings = "tnum" + val elapsedSeconds = state.elapsedTimeNanos.nanoseconds.inWholeSeconds + val minutes = elapsedSeconds / 60 + val seconds = elapsedSeconds % 60 + val formatRes = if (state.isPaused) { + R.string.elapsed_time_format_paused + } else { + R.string.elapsed_time_format + } + val format = stringResource(formatRes) + val formattedTime = remember(elapsedSeconds, format) { + format.format(minutes, seconds) + } + val accessibilityRes = if (state.isPaused) { + R.string.elapsed_time_accessibility_paused + } else { + R.string.elapsed_time_accessibility_recording + } + val accessibilityText = stringResource(accessibilityRes, minutes, seconds) + Box( + modifier = modifier + .testTag(ELAPSED_TIME_TAG) + .semantics(mergeDescendants = true) { + contentDescription = accessibilityText + } + .defaultMinSize(minWidth = 72.dp, minHeight = 32.dp) + .background(color = Color(0xFFED0000), shape = CircleShape) + .padding(horizontal = 12.dp, vertical = 6.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = formattedTime, + textAlign = TextAlign.Center, + color = Color.White, + style = MaterialTheme.typography.labelLarge.copy( + fontWeight = FontWeight.Bold, + fontFeatureSettings = "tnum", + letterSpacing = 0.sp + ) ) - ) + } } } @@ -966,3 +1007,62 @@ private fun FocusMeteringIndicator( } } } + +@Preview(name = "Elapsed Time", showBackground = true, backgroundColor = 0xFF000000) +@Composable +private fun ElapsedTimeTextPreview() { + // Assuming you have a JcaTheme in the google/jetpack-camera-app repository, + // you would typically wrap this in your custom theme. + MaterialTheme { + Column( + modifier = Modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Scenario 1: Initial recording state (0:00) + ElapsedTimeText( + elapsedTimeUiStateProvider = { + ElapsedTimeUiState.Enabled(0L) + } + ) + + // Scenario 2: Standard recording state + ElapsedTimeText( + elapsedTimeUiStateProvider = { + ElapsedTimeUiState.Enabled(30.seconds.inWholeNanoseconds) + } + ) + + // Scenario 3: Over a minute (1:05) + ElapsedTimeText( + elapsedTimeUiStateProvider = { + ElapsedTimeUiState.Enabled(65.seconds.inWholeNanoseconds) + } + ) + + // Scenario 4: Over 10 minutes (10:05) + ElapsedTimeText( + elapsedTimeUiStateProvider = { + ElapsedTimeUiState.Enabled(605.seconds.inWholeNanoseconds) + } + ) + + // Scenario 5: Paused recording state + ElapsedTimeText( + elapsedTimeUiStateProvider = { + ElapsedTimeUiState.Enabled( + elapsedTimeNanos = 30.seconds.inWholeNanoseconds, + isPaused = true + ) + } + ) + + // Scenario 6: Unavailable state (renders nothing, verifying the if-condition) + ElapsedTimeText( + elapsedTimeUiStateProvider = { + ElapsedTimeUiState.Unavailable + } + ) + } + } +} diff --git a/ui/components/capture/src/main/res/values/strings.xml b/ui/components/capture/src/main/res/values/strings.xml index f73e4d3e4..5dfbfaa2f 100644 --- a/ui/components/capture/src/main/res/values/strings.xml +++ b/ui/components/capture/src/main/res/values/strings.xml @@ -17,6 +17,10 @@ Camera Loading… + %1$d:%2$02d + PAUSED %1$d:%2$02d + Recording time: %1$d minutes and %2$d seconds + Recording paused: %1$d minutes and %2$d seconds Image capture mode diff --git a/ui/debug/build.gradle.kts b/ui/debug/build.gradle.kts index d62d15e16..b02567368 100644 --- a/ui/debug/build.gradle.kts +++ b/ui/debug/build.gradle.kts @@ -61,6 +61,7 @@ dependencies { // Compose - Material Design 3 implementation(libs.compose.material3) + implementation(libs.androidx.foundation.layout) // Compose - Android Studio Preview support implementation(libs.compose.ui.tooling.preview) diff --git a/ui/uistate/capture/src/main/java/com/google/jetpackcamera/ui/uistate/capture/ElapsedTimeUiState.kt b/ui/uistate/capture/src/main/java/com/google/jetpackcamera/ui/uistate/capture/ElapsedTimeUiState.kt index d4cd0ba34..5d6dd020a 100644 --- a/ui/uistate/capture/src/main/java/com/google/jetpackcamera/ui/uistate/capture/ElapsedTimeUiState.kt +++ b/ui/uistate/capture/src/main/java/com/google/jetpackcamera/ui/uistate/capture/ElapsedTimeUiState.kt @@ -32,8 +32,12 @@ sealed interface ElapsedTimeUiState { * The elapsed time display is enabled and showing the current recording time. * * @param elapsedTimeNanos The elapsed time in nanoseconds. + * @param isPaused Whether the recording is currently paused. */ - data class Enabled(val elapsedTimeNanos: Long) : ElapsedTimeUiState + data class Enabled( + val elapsedTimeNanos: Long, + val isPaused: Boolean = false + ) : ElapsedTimeUiState companion object } diff --git a/ui/uistateadapter/capture/src/main/java/com/google/jetpackcamera/ui/uistateadapter/capture/ElapsedTimeUiStateAdapter.kt b/ui/uistateadapter/capture/src/main/java/com/google/jetpackcamera/ui/uistateadapter/capture/ElapsedTimeUiStateAdapter.kt index 370814a34..facdc72f8 100644 --- a/ui/uistateadapter/capture/src/main/java/com/google/jetpackcamera/ui/uistateadapter/capture/ElapsedTimeUiStateAdapter.kt +++ b/ui/uistateadapter/capture/src/main/java/com/google/jetpackcamera/ui/uistateadapter/capture/ElapsedTimeUiStateAdapter.kt @@ -38,7 +38,10 @@ fun ElapsedTimeUiState.Companion.from(cameraState: CameraState): ElapsedTimeUiSt val videoRecordingState = cameraState.videoRecordingState return when (videoRecordingState) { is VideoRecordingState.Active -> - ElapsedTimeUiState.Enabled(videoRecordingState.elapsedTimeNanos) + ElapsedTimeUiState.Enabled( + elapsedTimeNanos = videoRecordingState.elapsedTimeNanos, + isPaused = videoRecordingState is VideoRecordingState.Active.Paused + ) is VideoRecordingState.Inactive -> ElapsedTimeUiState.Enabled(videoRecordingState.finalElapsedTimeNanos) diff --git a/ui/uistateadapter/capture/src/test/java/com/google/jetpackcamera/ui/uistateadapter/capture/ElapsedTimeUiStateAdapterTest.kt b/ui/uistateadapter/capture/src/test/java/com/google/jetpackcamera/ui/uistateadapter/capture/ElapsedTimeUiStateAdapterTest.kt new file mode 100644 index 000000000..00b7ead3f --- /dev/null +++ b/ui/uistateadapter/capture/src/test/java/com/google/jetpackcamera/ui/uistateadapter/capture/ElapsedTimeUiStateAdapterTest.kt @@ -0,0 +1,84 @@ +/* + * Copyright (C) 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.jetpackcamera.ui.uistateadapter.capture + +import com.google.common.truth.Truth.assertThat +import com.google.jetpackcamera.core.camera.CameraState +import com.google.jetpackcamera.core.camera.VideoRecordingState +import com.google.jetpackcamera.ui.uistate.capture.ElapsedTimeUiState +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@RunWith(JUnit4::class) +class ElapsedTimeUiStateAdapterTest { + + @Test + fun from_activeRecordingState_returnsEnabledWithCorrectNanosAndNotPaused() { + val cameraState = CameraState( + videoRecordingState = VideoRecordingState.Active.Recording( + maxDurationMillis = 0L, + audioAmplitude = 0.0, + elapsedTimeNanos = 123456L + ) + ) + val state = ElapsedTimeUiState.from(cameraState) + assertThat(state).isInstanceOf(ElapsedTimeUiState.Enabled::class.java) + val enabledState = state as ElapsedTimeUiState.Enabled + assertThat(enabledState.elapsedTimeNanos).isEqualTo(123456L) + assertThat(enabledState.isPaused).isFalse() + } + + @Test + fun from_pausedRecordingState_returnsEnabledWithCorrectNanosAndPaused() { + val cameraState = CameraState( + videoRecordingState = VideoRecordingState.Active.Paused( + maxDurationMillis = 0L, + audioAmplitude = 0.0, + elapsedTimeNanos = 654321L + ) + ) + val state = ElapsedTimeUiState.from(cameraState) + assertThat(state).isInstanceOf(ElapsedTimeUiState.Enabled::class.java) + val enabledState = state as ElapsedTimeUiState.Enabled + assertThat(enabledState.elapsedTimeNanos).isEqualTo(654321L) + assertThat(enabledState.isPaused).isTrue() + } + + @Test + fun from_inactiveRecordingState_returnsEnabledWithFinalNanosAndNotPaused() { + val cameraState = CameraState( + videoRecordingState = VideoRecordingState.Inactive(finalElapsedTimeNanos = 7890L) + ) + val state = ElapsedTimeUiState.from(cameraState) + assertThat(state).isInstanceOf(ElapsedTimeUiState.Enabled::class.java) + val enabledState = state as ElapsedTimeUiState.Enabled + assertThat(enabledState.elapsedTimeNanos).isEqualTo(7890L) + assertThat(enabledState.isPaused).isFalse() + } + + @Test + fun from_startingRecordingState_returnsEnabledWithZeroNanosAndNotPaused() { + val cameraState = CameraState( + videoRecordingState = VideoRecordingState.Starting() + ) + val state = ElapsedTimeUiState.from(cameraState) + assertThat(state).isInstanceOf(ElapsedTimeUiState.Enabled::class.java) + val enabledState = state as ElapsedTimeUiState.Enabled + assertThat(enabledState.elapsedTimeNanos).isEqualTo(0L) + assertThat(enabledState.isPaused).isFalse() + } +}