From b4f400c4ae7dbe38b4eea8e7b61ea006ea0d1ea4 Mon Sep 17 00:00:00 2001 From: Kimberly Crevecoeur Date: Mon, 13 Jul 2026 11:45:14 -0700 Subject: [PATCH 1/9] modernize elapsed time composable & include previews --- gradle/libs.versions.toml | 1 + ui/components/capture/build.gradle.kts | 1 + .../capture/CaptureScreenComponents.kt | 120 ++++++++++++++---- 3 files changed, 97 insertions(+), 25 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6094f3ab2..9274dbe64 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 = { group = "androidx.compose.foundation", name = "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/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 e193cf8e6..0508a9017 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 @@ -20,7 +20,6 @@ import android.content.pm.ActivityInfo import android.os.Build import android.util.Log import androidx.camera.compose.CameraXViewfinder -import androidx.camera.core.DynamicRange as CXDynamicRange import androidx.camera.core.SurfaceRequest import androidx.camera.viewfinder.compose.CoordinateTransformer import androidx.camera.viewfinder.compose.MutableCoordinateTransformer @@ -49,11 +48,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 +68,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,7 +95,10 @@ 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.text.TextStyle +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 @@ -116,12 +122,14 @@ import com.google.jetpackcamera.ui.uistate.capture.FlipLensUiState 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 kotlinx.coroutines.delay import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onCompletion +import kotlin.time.Duration.Companion.nanoseconds +import kotlin.time.Duration.Companion.seconds +import androidx.camera.core.DynamicRange as CXDynamicRange private const val TAG = "PreviewScreen" private const val BLINK_TIME = 100L @@ -132,7 +140,11 @@ private const val FOCUS_INDICATOR_RESULT_DELAY = 100L * A composable that displays the elapsed time of a video recording in a "MM:SS" format. * 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. + * @param textStyle the [TextStyle] to use for the elapsed time text. + * @param textColor the [Color] to use for the elapsed time text. + * @param containerColor the [Color] to use for the background container. */ @Composable fun ElapsedTimeText( @@ -141,18 +153,28 @@ 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" + Box( + modifier = modifier + .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 = state.elapsedTimeNanos.nanoseconds + .toComponents { minutes, seconds, _ -> "%d:%02d".format(minutes, seconds) }, + textAlign = TextAlign.Center, + color = Color.White, + style = MaterialTheme.typography.labelLarge.copy( + fontWeight = FontWeight.ExtraBold, + fontFeatureSettings = "tnum" + ) ) - ) + } } } + /** * A toggle button that allows the user to pause and resume video recording. * @@ -298,9 +320,9 @@ fun CaptureModeToggleButton( val enabled = uiState.isCaptureModeSelectable(CaptureMode.VIDEO_ONLY) && - uiState.isCaptureModeSelectable( - CaptureMode.IMAGE_ONLY - ) && uiState.selectedCaptureMode != CaptureMode.STANDARD + uiState.isCaptureModeSelectable( + CaptureMode.IMAGE_ONLY + ) && uiState.selectedCaptureMode != CaptureMode.STANDARD ToggleSwitch( modifier = modifier.testTag(CAPTURE_MODE_TOGGLE_BUTTON), @@ -312,13 +334,13 @@ fun CaptureModeToggleButton( onToggleWhenDisabled = { val disabledReason: DisableRationale? = ( - uiState.findSelectableStateFor(CaptureMode.VIDEO_ONLY) as? - SingleSelectableUiState.Disabled - )?.disabledReason + uiState.findSelectableStateFor(CaptureMode.VIDEO_ONLY) as? + SingleSelectableUiState.Disabled + )?.disabledReason ?: ( - uiState.findSelectableStateFor(CaptureMode.IMAGE_ONLY) - as? SingleSelectableUiState.Disabled - ) + uiState.findSelectableStateFor(CaptureMode.IMAGE_ONLY) + as? SingleSelectableUiState.Disabled + ) ?.disabledReason disabledReason?.let { snackBarController?.enqueueDisabledHdrToggleSnackBar(it) } }, @@ -565,7 +587,7 @@ fun PreviewDisplay( Log.d( "TAG", "onTapToFocus: " + - "input{$it} -> surface{$surfaceCoords}" + "input{$it} -> surface{$surfaceCoords}" ) onTapToFocus(surfaceCoords.x, surfaceCoords.y) } @@ -690,8 +712,8 @@ fun StabilizationIcon(stabilizationUiState: StabilizationUiState, modifier: Modi else -> TODO( "Cannot retrieve icon for unimplemented " + - "stabilization mode:" + - "${stabilizationUiState.stabilizationMode}" + "stabilization mode:" + + "${stabilizationUiState.stabilizationMode}" ) } @@ -706,8 +728,8 @@ fun StabilizationIcon(stabilizationUiState: StabilizationUiState, modifier: Modi else -> TODO( "Auto stabilization not yet implemented for " + - "${stabilizationUiState.stabilizationMode}, " + - "unable to retrieve icon." + "${stabilizationUiState.stabilizationMode}, " + + "unable to retrieve icon." ) } } @@ -950,3 +972,51 @@ 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), + 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 4: Unavailable state (renders nothing, verifying the if-condition) + ElapsedTimeText( + elapsedTimeUiStateProvider = { + ElapsedTimeUiState.Unavailable + } + ) + } + } +} From f274b24400991f1d728c88f80ec15fa68d72b9b7 Mon Sep 17 00:00:00 2001 From: Kimberly Crevecoeur Date: Wed, 15 Jul 2026 11:56:49 -0700 Subject: [PATCH 2/9] address PR comment --- .../capture/CaptureScreenComponents.kt | 31 ++++++++++++++----- 1 file changed, 23 insertions(+), 8 deletions(-) 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 0508a9017..42c97fad5 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 @@ -73,6 +73,7 @@ import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -137,22 +138,37 @@ 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. - * @param textStyle the [TextStyle] to use for the elapsed time text. - * @param textColor the [Color] to use for the elapsed time text. - * @param containerColor the [Color] to use for the background container. */ @Composable fun ElapsedTimeText( modifier: Modifier = Modifier, elapsedTimeUiStateProvider: () -> ElapsedTimeUiState ) { - val state = elapsedTimeUiStateProvider() - if (state is ElapsedTimeUiState.Enabled) { + // derivedStateOf prevents recomposing ElapsedTimeText when the timer ticks. + // Recomposition only occurs when visibility (isEnabled) changes. + val isEnabled by remember(elapsedTimeUiStateProvider) { + derivedStateOf { + elapsedTimeUiStateProvider() is ElapsedTimeUiState.Enabled + } + } + if (isEnabled) { + // derivedStateOf defers reading the provider to the Box content scope, ensuring only the Text component recomposes every second. + val formattedTime by remember(elapsedTimeUiStateProvider) { + derivedStateOf { + val state = elapsedTimeUiStateProvider() + if (state is ElapsedTimeUiState.Enabled) { + state.elapsedTimeNanos.nanoseconds + .toComponents { minutes, seconds, _ -> "%d:%02d".format(minutes, seconds) } + } else { + "" + } + } + } Box( modifier = modifier .defaultMinSize(minWidth = 72.dp, minHeight = 32.dp) @@ -161,8 +177,7 @@ fun ElapsedTimeText( contentAlignment = Alignment.Center ) { Text( - text = state.elapsedTimeNanos.nanoseconds - .toComponents { minutes, seconds, _ -> "%d:%02d".format(minutes, seconds) }, + text = formattedTime, textAlign = TextAlign.Center, color = Color.White, style = MaterialTheme.typography.labelLarge.copy( From efdbe9e9722dca79556bde45257a6a20b028f8cf Mon Sep 17 00:00:00 2001 From: Kimberly Crevecoeur Date: Wed, 15 Jul 2026 12:19:26 -0700 Subject: [PATCH 3/9] spotless --- .../capture/CaptureScreenComponents.kt | 38 +++++++++---------- 1 file changed, 18 insertions(+), 20 deletions(-) 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 42c97fad5..4af2dbdda 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 @@ -20,6 +20,7 @@ import android.content.pm.ActivityInfo import android.os.Build import android.util.Log import androidx.camera.compose.CameraXViewfinder +import androidx.camera.core.DynamicRange as CXDynamicRange import androidx.camera.core.SurfaceRequest import androidx.camera.viewfinder.compose.CoordinateTransformer import androidx.camera.viewfinder.compose.MutableCoordinateTransformer @@ -73,9 +74,9 @@ import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf @@ -96,7 +97,6 @@ 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.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview @@ -123,14 +123,13 @@ import com.google.jetpackcamera.ui.uistate.capture.FlipLensUiState 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 import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onCompletion -import kotlin.time.Duration.Companion.nanoseconds -import kotlin.time.Duration.Companion.seconds -import androidx.camera.core.DynamicRange as CXDynamicRange private const val TAG = "PreviewScreen" private const val BLINK_TIME = 100L @@ -189,7 +188,6 @@ fun ElapsedTimeText( } } - /** * A toggle button that allows the user to pause and resume video recording. * @@ -335,9 +333,9 @@ fun CaptureModeToggleButton( val enabled = uiState.isCaptureModeSelectable(CaptureMode.VIDEO_ONLY) && - uiState.isCaptureModeSelectable( - CaptureMode.IMAGE_ONLY - ) && uiState.selectedCaptureMode != CaptureMode.STANDARD + uiState.isCaptureModeSelectable( + CaptureMode.IMAGE_ONLY + ) && uiState.selectedCaptureMode != CaptureMode.STANDARD ToggleSwitch( modifier = modifier.testTag(CAPTURE_MODE_TOGGLE_BUTTON), @@ -349,13 +347,13 @@ fun CaptureModeToggleButton( onToggleWhenDisabled = { val disabledReason: DisableRationale? = ( - uiState.findSelectableStateFor(CaptureMode.VIDEO_ONLY) as? - SingleSelectableUiState.Disabled - )?.disabledReason + uiState.findSelectableStateFor(CaptureMode.VIDEO_ONLY) as? + SingleSelectableUiState.Disabled + )?.disabledReason ?: ( - uiState.findSelectableStateFor(CaptureMode.IMAGE_ONLY) - as? SingleSelectableUiState.Disabled - ) + uiState.findSelectableStateFor(CaptureMode.IMAGE_ONLY) + as? SingleSelectableUiState.Disabled + ) ?.disabledReason disabledReason?.let { snackBarController?.enqueueDisabledHdrToggleSnackBar(it) } }, @@ -602,7 +600,7 @@ fun PreviewDisplay( Log.d( "TAG", "onTapToFocus: " + - "input{$it} -> surface{$surfaceCoords}" + "input{$it} -> surface{$surfaceCoords}" ) onTapToFocus(surfaceCoords.x, surfaceCoords.y) } @@ -727,8 +725,8 @@ fun StabilizationIcon(stabilizationUiState: StabilizationUiState, modifier: Modi else -> TODO( "Cannot retrieve icon for unimplemented " + - "stabilization mode:" + - "${stabilizationUiState.stabilizationMode}" + "stabilization mode:" + + "${stabilizationUiState.stabilizationMode}" ) } @@ -743,8 +741,8 @@ fun StabilizationIcon(stabilizationUiState: StabilizationUiState, modifier: Modi else -> TODO( "Auto stabilization not yet implemented for " + - "${stabilizationUiState.stabilizationMode}, " + - "unable to retrieve icon." + "${stabilizationUiState.stabilizationMode}, " + + "unable to retrieve icon." ) } } From 5512d31c9607680237817c0476694c86d9fd7954 Mon Sep 17 00:00:00 2001 From: Kimberly Crevecoeur Date: Tue, 21 Jul 2026 14:05:37 -0700 Subject: [PATCH 4/9] tidy up components and previe --- gradle/libs.versions.toml | 2 +- .../capture/CaptureScreenComponents.kt | 26 +++++-------------- 2 files changed, 7 insertions(+), 21 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9274dbe64..fcc3b135d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -114,7 +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 = { group = "androidx.compose.foundation", name = "foundation-layout" } +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/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 4af2dbdda..505954233 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 @@ -148,25 +148,11 @@ fun ElapsedTimeText( modifier: Modifier = Modifier, elapsedTimeUiStateProvider: () -> ElapsedTimeUiState ) { - // derivedStateOf prevents recomposing ElapsedTimeText when the timer ticks. - // Recomposition only occurs when visibility (isEnabled) changes. - val isEnabled by remember(elapsedTimeUiStateProvider) { - derivedStateOf { - elapsedTimeUiStateProvider() is ElapsedTimeUiState.Enabled - } - } - if (isEnabled) { - // derivedStateOf defers reading the provider to the Box content scope, ensuring only the Text component recomposes every second. - val formattedTime by remember(elapsedTimeUiStateProvider) { - derivedStateOf { - val state = elapsedTimeUiStateProvider() - if (state is ElapsedTimeUiState.Enabled) { - state.elapsedTimeNanos.nanoseconds - .toComponents { minutes, seconds, _ -> "%d:%02d".format(minutes, seconds) } - } else { - "" - } - } + val state = elapsedTimeUiStateProvider() + if (state is ElapsedTimeUiState.Enabled) { + val formattedTime = remember(state.elapsedTimeNanos) { + state.elapsedTimeNanos.nanoseconds + .toComponents { minutes, seconds, _ -> "%d:%02d".format(minutes, seconds) } } Box( modifier = modifier @@ -1024,7 +1010,7 @@ private fun ElapsedTimeTextPreview() { } ) - // Scenario 4: Unavailable state (renders nothing, verifying the if-condition) + // Scenario 5: Unavailable state (renders nothing, verifying the if-condition) ElapsedTimeText( elapsedTimeUiStateProvider = { ElapsedTimeUiState.Unavailable From 7b7dd8956d9f7e2bcb6e5a018a794f788a2c1e60 Mon Sep 17 00:00:00 2001 From: Kimberly Crevecoeur Date: Mon, 27 Jul 2026 10:36:34 -0700 Subject: [PATCH 5/9] adjust according to design --- .../ui/components/capture/CaptureScreenComponents.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 505954233..61ae139a7 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 @@ -102,6 +102,7 @@ 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.sp import androidx.compose.ui.unit.round import com.google.jetpackcamera.core.camera.VideoRecordingState import com.google.jetpackcamera.model.CaptureMode @@ -166,8 +167,9 @@ fun ElapsedTimeText( textAlign = TextAlign.Center, color = Color.White, style = MaterialTheme.typography.labelLarge.copy( - fontWeight = FontWeight.ExtraBold, - fontFeatureSettings = "tnum" + fontWeight = FontWeight.Bold, + fontFeatureSettings = "tnum", + letterSpacing = 0.sp ) ) } From 4e9d5193236febba5e12552f1705a6d2894ad4ca Mon Sep 17 00:00:00 2001 From: Kimberly Crevecoeur Date: Wed, 29 Jul 2026 11:39:36 -0700 Subject: [PATCH 6/9] Refactor ElapsedTimeText to support paused video recording state - Update ElapsedTimeUiState.Enabled to include an isPaused flag. - Update ElapsedTimeUiStateAdapter to map VideoRecordingState.Active.Paused to the new paused UI state. - Extract hardcoded video duration format strings to strings.xml. - Implement paused text display and optimize remember key in ElapsedTimeText. - Add explicit foundation-layout dependency in ui:debug module. - Add unit tests for ElapsedTimeUiStateAdapter and component tests for ElapsedTimeText. - Fix constructor call compilation in CameraXCameraSystemTest. --- .../core/camera/CameraXCameraSystemTest.kt | 1 + .../capture/CaptureScreenComponentsTest.kt | 91 +++++++++++++++++++ .../capture/CaptureScreenComponents.kt | 67 +++++++++----- .../capture/src/main/res/values/strings.xml | 2 + ui/debug/build.gradle.kts | 1 + .../ui/uistate/capture/ElapsedTimeUiState.kt | 6 +- .../capture/ElapsedTimeUiStateAdapter.kt | 5 +- .../capture/ElapsedTimeUiStateAdapterTest.kt | 84 +++++++++++++++++ 8 files changed, 232 insertions(+), 25 deletions(-) create mode 100644 ui/components/capture/src/androidTest/java/com/google/jetpackcamera/ui/components/capture/CaptureScreenComponentsTest.kt create mode 100644 ui/uistateadapter/capture/src/test/java/com/google/jetpackcamera/ui/uistateadapter/capture/ElapsedTimeUiStateAdapterTest.kt diff --git a/core/camera/src/androidTest/java/com/google/jetpackcamera/core/camera/CameraXCameraSystemTest.kt b/core/camera/src/androidTest/java/com/google/jetpackcamera/core/camera/CameraXCameraSystemTest.kt index 4fa50a202..cf6cfa717 100644 --- a/core/camera/src/androidTest/java/com/google/jetpackcamera/core/camera/CameraXCameraSystemTest.kt +++ b/core/camera/src/androidTest/java/com/google/jetpackcamera/core/camera/CameraXCameraSystemTest.kt @@ -351,6 +351,7 @@ class CameraXCameraSystemTest { availabilityCheckers = emptyMap(), effectProviders = emptyMap(), imagePostProcessors = getFakePostProcessorMap(fakeImagePostProcessor), + cameraEffectProviders = emptyMap(), filePathGenerator = FakeFilePathGenerator() ).apply { initialize(appSettings) {} 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..9dd724b79 --- /dev/null +++ b/ui/components/capture/src/androidTest/java/com/google/jetpackcamera/ui/components/capture/CaptureScreenComponentsTest.kt @@ -0,0 +1,91 @@ +/* + * Copyright (C) 2025 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.runtime.mutableStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +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() + } + + @Test + fun elapsedTimeText_enabledState_displaysFormattedTime() { + val uiState = mutableStateOf(ElapsedTimeUiState.Enabled(0L)) + composeTestRule.setContent { + ElapsedTimeText( + modifier = Modifier.testTag(ELAPSED_TIME_TAG), + elapsedTimeUiStateProvider = { uiState.value } + ) + } + + val timeStates = mapOf( + 0L to "0:00", + 30_000_000_000L to "0:30", + 65_000_000_000L to "1:05", + 605_000_000_000L to "10:05" + ) + + timeStates.forEach { (nanos, expectedText) -> + uiState.value = ElapsedTimeUiState.Enabled(nanos) + composeTestRule.onNodeWithTag(ELAPSED_TIME_TAG).assertTextEquals(expectedText) + } + } + + @Test + fun elapsedTimeText_pausedState_displaysPausedFormattedTime() { + val uiState = mutableStateOf(ElapsedTimeUiState.Enabled(0L, isPaused = true)) + composeTestRule.setContent { + ElapsedTimeText( + modifier = Modifier.testTag(ELAPSED_TIME_TAG), + elapsedTimeUiStateProvider = { uiState.value } + ) + } + + val timeStates = mapOf( + 0L to "PAUSED 0:00", + 30_000_000_000L to "PAUSED 0:30", + 65_000_000_000L to "PAUSED 1:05", + 605_000_000_000L to "PAUSED 10:05" + ) + + timeStates.forEach { (nanos, expectedText) -> + uiState.value = ElapsedTimeUiState.Enabled(nanos, isPaused = true) + composeTestRule.onNodeWithTag(ELAPSED_TIME_TAG).assertTextEquals(expectedText) + } + } +} 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 61ae139a7..934d25219 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 @@ -20,7 +20,6 @@ import android.content.pm.ActivityInfo import android.os.Build import android.util.Log import androidx.camera.compose.CameraXViewfinder -import androidx.camera.core.DynamicRange as CXDynamicRange import androidx.camera.core.SurfaceRequest import androidx.camera.viewfinder.compose.CoordinateTransformer import androidx.camera.viewfinder.compose.MutableCoordinateTransformer @@ -76,7 +75,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf @@ -97,13 +95,14 @@ 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.semantics 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.sp 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 @@ -124,13 +123,14 @@ import com.google.jetpackcamera.ui.uistate.capture.FlipLensUiState 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 import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onCompletion +import kotlin.time.Duration.Companion.nanoseconds +import kotlin.time.Duration.Companion.seconds +import androidx.camera.core.DynamicRange as CXDynamicRange private const val TAG = "PreviewScreen" private const val BLINK_TIME = 100L @@ -151,12 +151,22 @@ fun ElapsedTimeText( ) { val state = elapsedTimeUiStateProvider() if (state is ElapsedTimeUiState.Enabled) { - val formattedTime = remember(state.elapsedTimeNanos) { - state.elapsedTimeNanos.nanoseconds - .toComponents { minutes, seconds, _ -> "%d:%02d".format(minutes, seconds) } + val elapsedSeconds = state.elapsedTimeNanos.nanoseconds.inWholeSeconds + 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) { + val minutes = elapsedSeconds / 60 + val seconds = elapsedSeconds % 60 + format.format(minutes, seconds) } Box( modifier = modifier + .testTag(ELAPSED_TIME_TAG) + .semantics(mergeDescendants = true){} .defaultMinSize(minWidth = 72.dp, minHeight = 32.dp) .background(color = Color(0xFFED0000), shape = CircleShape) .padding(horizontal = 12.dp, vertical = 6.dp), @@ -321,9 +331,9 @@ fun CaptureModeToggleButton( val enabled = uiState.isCaptureModeSelectable(CaptureMode.VIDEO_ONLY) && - uiState.isCaptureModeSelectable( - CaptureMode.IMAGE_ONLY - ) && uiState.selectedCaptureMode != CaptureMode.STANDARD + uiState.isCaptureModeSelectable( + CaptureMode.IMAGE_ONLY + ) && uiState.selectedCaptureMode != CaptureMode.STANDARD ToggleSwitch( modifier = modifier.testTag(CAPTURE_MODE_TOGGLE_BUTTON), @@ -335,13 +345,13 @@ fun CaptureModeToggleButton( onToggleWhenDisabled = { val disabledReason: DisableRationale? = ( - uiState.findSelectableStateFor(CaptureMode.VIDEO_ONLY) as? - SingleSelectableUiState.Disabled - )?.disabledReason + uiState.findSelectableStateFor(CaptureMode.VIDEO_ONLY) as? + SingleSelectableUiState.Disabled + )?.disabledReason ?: ( - uiState.findSelectableStateFor(CaptureMode.IMAGE_ONLY) - as? SingleSelectableUiState.Disabled - ) + uiState.findSelectableStateFor(CaptureMode.IMAGE_ONLY) + as? SingleSelectableUiState.Disabled + ) ?.disabledReason disabledReason?.let { snackBarController?.enqueueDisabledHdrToggleSnackBar(it) } }, @@ -588,7 +598,7 @@ fun PreviewDisplay( Log.d( "TAG", "onTapToFocus: " + - "input{$it} -> surface{$surfaceCoords}" + "input{$it} -> surface{$surfaceCoords}" ) onTapToFocus(surfaceCoords.x, surfaceCoords.y) } @@ -713,8 +723,8 @@ fun StabilizationIcon(stabilizationUiState: StabilizationUiState, modifier: Modi else -> TODO( "Cannot retrieve icon for unimplemented " + - "stabilization mode:" + - "${stabilizationUiState.stabilizationMode}" + "stabilization mode:" + + "${stabilizationUiState.stabilizationMode}" ) } @@ -729,8 +739,8 @@ fun StabilizationIcon(stabilizationUiState: StabilizationUiState, modifier: Modi else -> TODO( "Auto stabilization not yet implemented for " + - "${stabilizationUiState.stabilizationMode}, " + - "unable to retrieve icon." + "${stabilizationUiState.stabilizationMode}, " + + "unable to retrieve icon." ) } } @@ -982,6 +992,7 @@ private fun ElapsedTimeTextPreview() { MaterialTheme { Column( modifier = Modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp) ) { // Scenario 1: Initial recording state (0:00) @@ -1012,7 +1023,17 @@ private fun ElapsedTimeTextPreview() { } ) - // Scenario 5: Unavailable state (renders nothing, verifying the if-condition) + // 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 87f58b335..ccead9ca6 100644 --- a/ui/components/capture/src/main/res/values/strings.xml +++ b/ui/components/capture/src/main/res/values/strings.xml @@ -17,6 +17,8 @@ Camera Loading… + %1$d:%2$02d + PAUSED %1$d:%2$02d 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() + } +} From 2c6c138af15f5999980cf0dc97f5f0a1087a058a Mon Sep 17 00:00:00 2001 From: Kimberly Crevecoeur Date: Wed, 29 Jul 2026 12:15:28 -0700 Subject: [PATCH 7/9] Add TalkBack support and accessibility checks to ElapsedTimeText - Set custom content descriptions for active and paused recording states to improve TalkBack announcements. - Add decoupled tests to verify text and accessibility properties in CaptureScreenComponentsTest. - Integrate ATF contrast checks in UI test rendering scenarios. --- .../capture/CaptureScreenComponentsTest.kt | 61 ++++++++++--------- .../capture/CaptureScreenComponents.kt | 15 ++++- .../capture/src/main/res/values/strings.xml | 2 + 3 files changed, 47 insertions(+), 31 deletions(-) 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 index 9dd724b79..05238d3d0 100644 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright (C) 2025 The Android Open Source Project + * 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. @@ -15,12 +15,14 @@ */ package com.google.jetpackcamera.ui.components.capture -import androidx.compose.runtime.mutableStateOf 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 @@ -41,51 +43,54 @@ class CaptureScreenComponentsTest { ) } composeTestRule.onNodeWithTag(ELAPSED_TIME_TAG).assertDoesNotExist() + composeTestRule.onRoot().tryPerformAccessibilityChecks() } @Test - fun elapsedTimeText_enabledState_displaysFormattedTime() { - val uiState = mutableStateOf(ElapsedTimeUiState.Enabled(0L)) + fun elapsedTimeText_enabledActive_displaysFormattedTime() { composeTestRule.setContent { ElapsedTimeText( modifier = Modifier.testTag(ELAPSED_TIME_TAG), - elapsedTimeUiStateProvider = { uiState.value } + elapsedTimeUiStateProvider = { ElapsedTimeUiState.Enabled(30_000_000_000L) } // 0:30 ) } + composeTestRule.onNodeWithTag(ELAPSED_TIME_TAG).assertTextEquals("0:30") + composeTestRule.onRoot().tryPerformAccessibilityChecks() + } - val timeStates = mapOf( - 0L to "0:00", - 30_000_000_000L to "0:30", - 65_000_000_000L to "1:05", - 605_000_000_000L to "10:05" - ) - - timeStates.forEach { (nanos, expectedText) -> - uiState.value = ElapsedTimeUiState.Enabled(nanos) - composeTestRule.onNodeWithTag(ELAPSED_TIME_TAG).assertTextEquals(expectedText) + @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_pausedState_displaysPausedFormattedTime() { - val uiState = mutableStateOf(ElapsedTimeUiState.Enabled(0L, isPaused = true)) + fun elapsedTimeText_enabledPaused_displaysPausedFormattedTime() { composeTestRule.setContent { ElapsedTimeText( modifier = Modifier.testTag(ELAPSED_TIME_TAG), - elapsedTimeUiStateProvider = { uiState.value } + elapsedTimeUiStateProvider = { ElapsedTimeUiState.Enabled(30_000_000_000L, isPaused = true) } // PAUSED 0:30 ) } + composeTestRule.onNodeWithTag(ELAPSED_TIME_TAG).assertTextEquals("PAUSED 0:30") + composeTestRule.onRoot().tryPerformAccessibilityChecks() + } - val timeStates = mapOf( - 0L to "PAUSED 0:00", - 30_000_000_000L to "PAUSED 0:30", - 65_000_000_000L to "PAUSED 1:05", - 605_000_000_000L to "PAUSED 10:05" - ) - - timeStates.forEach { (nanos, expectedText) -> - uiState.value = ElapsedTimeUiState.Enabled(nanos, isPaused = true) - composeTestRule.onNodeWithTag(ELAPSED_TIME_TAG).assertTextEquals(expectedText) + @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 934d25219..00265e1d3 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 @@ -95,6 +95,7 @@ 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.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -152,6 +153,8 @@ fun ElapsedTimeText( val state = elapsedTimeUiStateProvider() if (state is ElapsedTimeUiState.Enabled) { 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 { @@ -159,14 +162,20 @@ fun ElapsedTimeText( } val format = stringResource(formatRes) val formattedTime = remember(elapsedSeconds, format) { - val minutes = elapsedSeconds / 60 - val seconds = elapsedSeconds % 60 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){} + .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), diff --git a/ui/components/capture/src/main/res/values/strings.xml b/ui/components/capture/src/main/res/values/strings.xml index ccead9ca6..6a96f92c7 100644 --- a/ui/components/capture/src/main/res/values/strings.xml +++ b/ui/components/capture/src/main/res/values/strings.xml @@ -19,6 +19,8 @@ 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 From f98bc27c6736ae0894c1fb8e890b6ce478ffb646 Mon Sep 17 00:00:00 2001 From: Kimberly Crevecoeur Date: Sat, 1 Aug 2026 06:02:27 -0700 Subject: [PATCH 8/9] spotless --- .../capture/CaptureScreenComponentsTest.kt | 8 +++-- .../capture/CaptureScreenComponents.kt | 36 +++++++++---------- 2 files changed, 24 insertions(+), 20 deletions(-) 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 index 05238d3d0..10d14afef 100644 --- 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 @@ -75,7 +75,9 @@ class CaptureScreenComponentsTest { composeTestRule.setContent { ElapsedTimeText( modifier = Modifier.testTag(ELAPSED_TIME_TAG), - elapsedTimeUiStateProvider = { ElapsedTimeUiState.Enabled(30_000_000_000L, isPaused = true) } // PAUSED 0:30 + elapsedTimeUiStateProvider = { + ElapsedTimeUiState.Enabled(30_000_000_000L, isPaused = true) + } // PAUSED 0:30 ) } composeTestRule.onNodeWithTag(ELAPSED_TIME_TAG).assertTextEquals("PAUSED 0:30") @@ -87,7 +89,9 @@ class CaptureScreenComponentsTest { composeTestRule.setContent { ElapsedTimeText( modifier = Modifier.testTag(ELAPSED_TIME_TAG), - elapsedTimeUiStateProvider = { ElapsedTimeUiState.Enabled(65_000_000_000L, isPaused = true) } // PAUSED 1:05 + elapsedTimeUiStateProvider = { + ElapsedTimeUiState.Enabled(65_000_000_000L, isPaused = true) + } // PAUSED 1:05 ) } composeTestRule.onNodeWithTag(ELAPSED_TIME_TAG) 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 7b6be656b..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 @@ -20,6 +20,7 @@ import android.content.pm.ActivityInfo import android.os.Build import android.util.Log import androidx.camera.compose.CameraXViewfinder +import androidx.camera.core.DynamicRange as CXDynamicRange import androidx.camera.core.SurfaceRequest import androidx.camera.viewfinder.compose.CoordinateTransformer import androidx.camera.viewfinder.compose.MutableCoordinateTransformer @@ -97,8 +98,8 @@ 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.text.font.FontWeight 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 @@ -125,14 +126,13 @@ import com.google.jetpackcamera.ui.uistate.capture.FlipLensUiState 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 import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onCompletion -import kotlin.time.Duration.Companion.nanoseconds -import kotlin.time.Duration.Companion.seconds -import androidx.camera.core.DynamicRange as CXDynamicRange private const val TAG = "PreviewScreen" private const val BLINK_TIME = 100L @@ -355,9 +355,9 @@ fun CaptureModeToggleButton( val enabled = uiState.isCaptureModeSelectable(CaptureMode.VIDEO_ONLY) && - uiState.isCaptureModeSelectable( - CaptureMode.IMAGE_ONLY - ) && uiState.selectedCaptureMode != CaptureMode.STANDARD + uiState.isCaptureModeSelectable( + CaptureMode.IMAGE_ONLY + ) && uiState.selectedCaptureMode != CaptureMode.STANDARD ToggleSwitch( modifier = modifier.testTag(CAPTURE_MODE_TOGGLE_BUTTON), @@ -369,13 +369,13 @@ fun CaptureModeToggleButton( onToggleWhenDisabled = { val disabledReason: DisableRationale? = ( - uiState.findSelectableStateFor(CaptureMode.VIDEO_ONLY) as? - SingleSelectableUiState.Disabled - )?.disabledReason + uiState.findSelectableStateFor(CaptureMode.VIDEO_ONLY) as? + SingleSelectableUiState.Disabled + )?.disabledReason ?: ( - uiState.findSelectableStateFor(CaptureMode.IMAGE_ONLY) - as? SingleSelectableUiState.Disabled - ) + uiState.findSelectableStateFor(CaptureMode.IMAGE_ONLY) + as? SingleSelectableUiState.Disabled + ) ?.disabledReason disabledReason?.let { snackBarController?.enqueueDisabledHdrToggleSnackBar(it) } }, @@ -622,7 +622,7 @@ fun PreviewDisplay( Log.d( "TAG", "onTapToFocus: " + - "input{$it} -> surface{$surfaceCoords}" + "input{$it} -> surface{$surfaceCoords}" ) onTapToFocus(surfaceCoords.x, surfaceCoords.y) } @@ -747,8 +747,8 @@ fun StabilizationIcon(stabilizationUiState: StabilizationUiState, modifier: Modi else -> TODO( "Cannot retrieve icon for unimplemented " + - "stabilization mode:" + - "${stabilizationUiState.stabilizationMode}" + "stabilization mode:" + + "${stabilizationUiState.stabilizationMode}" ) } @@ -763,8 +763,8 @@ fun StabilizationIcon(stabilizationUiState: StabilizationUiState, modifier: Modi else -> TODO( "Auto stabilization not yet implemented for " + - "${stabilizationUiState.stabilizationMode}, " + - "unable to retrieve icon." + "${stabilizationUiState.stabilizationMode}, " + + "unable to retrieve icon." ) } } From 37d1f64d099553ed92b3cabfc4a033e011adb36d Mon Sep 17 00:00:00 2001 From: Kimberly Crevecoeur Date: Mon, 3 Aug 2026 08:49:49 -0700 Subject: [PATCH 9/9] reformat comments --- .../capture/CaptureScreenComponentsTest.kt | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) 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 index 10d14afef..1e738180a 100644 --- 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 @@ -51,7 +51,8 @@ class CaptureScreenComponentsTest { composeTestRule.setContent { ElapsedTimeText( modifier = Modifier.testTag(ELAPSED_TIME_TAG), - elapsedTimeUiStateProvider = { ElapsedTimeUiState.Enabled(30_000_000_000L) } // 0:30 + // 0:30 + elapsedTimeUiStateProvider = { ElapsedTimeUiState.Enabled(30_000_000_000L) } ) } composeTestRule.onNodeWithTag(ELAPSED_TIME_TAG).assertTextEquals("0:30") @@ -63,7 +64,8 @@ class CaptureScreenComponentsTest { composeTestRule.setContent { ElapsedTimeText( modifier = Modifier.testTag(ELAPSED_TIME_TAG), - elapsedTimeUiStateProvider = { ElapsedTimeUiState.Enabled(65_000_000_000L) } // 1:05 + // 1:05 + elapsedTimeUiStateProvider = { ElapsedTimeUiState.Enabled(65_000_000_000L) } ) } composeTestRule.onNodeWithTag(ELAPSED_TIME_TAG) @@ -76,8 +78,9 @@ class CaptureScreenComponentsTest { ElapsedTimeText( modifier = Modifier.testTag(ELAPSED_TIME_TAG), elapsedTimeUiStateProvider = { + // PAUSED 0:30 ElapsedTimeUiState.Enabled(30_000_000_000L, isPaused = true) - } // PAUSED 0:30 + } ) } composeTestRule.onNodeWithTag(ELAPSED_TIME_TAG).assertTextEquals("PAUSED 0:30") @@ -90,8 +93,9 @@ class CaptureScreenComponentsTest { ElapsedTimeText( modifier = Modifier.testTag(ELAPSED_TIME_TAG), elapsedTimeUiStateProvider = { + // PAUSED 1:05 ElapsedTimeUiState.Enabled(65_000_000_000L, isPaused = true) - } // PAUSED 1:05 + } ) } composeTestRule.onNodeWithTag(ELAPSED_TIME_TAG)