Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
1 change: 1 addition & 0 deletions ui/components/capture/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/*
* 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),
// 0:30
elapsedTimeUiStateProvider = { ElapsedTimeUiState.Enabled(30_000_000_000L) }
)
}
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),
// 1:05
elapsedTimeUiStateProvider = { ElapsedTimeUiState.Enabled(65_000_000_000L) }
)
}
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 = {
// PAUSED 0:30
ElapsedTimeUiState.Enabled(30_000_000_000L, isPaused = true)
}
)
}
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 = {
// PAUSED 1:05
ElapsedTimeUiState.Enabled(65_000_000_000L, isPaused = true)
}
)
}
composeTestRule.onNodeWithTag(ELAPSED_TIME_TAG)
.assertContentDescriptionEquals("Recording paused: 1 minutes and 5 seconds")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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.
*/
Comment thread
Kimblebee marked this conversation as resolved.
@Composable
Expand All @@ -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
}
Comment thread
Kimblebee marked this conversation as resolved.
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(
Comment thread
Kimblebee marked this conversation as resolved.
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
)
)
)
}
}
}

Expand Down Expand Up @@ -966,3 +1007,62 @@ private fun FocusMeteringIndicator(
}
}
}

@Preview(name = "Elapsed Time", showBackground = true, backgroundColor = 0xFF000000)
Comment thread
Kimblebee marked this conversation as resolved.
@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
}
)
}
}
}
4 changes: 4 additions & 0 deletions ui/components/capture/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@
<resources>
<!-- Preview Screen Text -->
<string name="camera_not_ready">Camera Loading…</string>
<string name="elapsed_time_format">%1$d:%2$02d</string>
<string name="elapsed_time_format_paused">PAUSED %1$d:%2$02d</string>
<string name="elapsed_time_accessibility_recording">Recording time: %1$d minutes and %2$d seconds</string>
<string name="elapsed_time_accessibility_paused">Recording paused: %1$d minutes and %2$d seconds</string>

<!-- Capture Mode Toggle -->
<string name="capture_mode_image_capture_content_description">Image capture mode</string>
Expand Down
1 change: 1 addition & 0 deletions ui/debug/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading