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
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,13 +69,14 @@ 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
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
Expand All @@ -92,9 +97,12 @@ 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.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 com.google.jetpackcamera.core.camera.VideoRecordingState
import com.google.jetpackcamera.model.CaptureMode
Expand All @@ -117,6 +125,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 @@ -129,9 +138,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 @@ -141,15 +151,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"
val formattedTime = remember(state.elapsedTimeNanos) {
state.elapsedTimeNanos.nanoseconds
.toComponents { minutes, seconds, _ -> "%d:%02d".format(minutes, seconds) }
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

According to the mocks for the paused state, the badge should read PAUSED 0:02 when video recording is paused, rather than just the time.

To conditionally prepend the "PAUSED " string here, we'll need to know if the recording is paused. Since ElapsedTimeUiState.Enabled doesn't currently expose this, we should:

  1. Add an isPaused: Boolean property to ElapsedTimeUiState.Enabled (or create a new ElapsedTimeUiState.Paused variant).
  2. Update ElapsedTimeUiState.Companion.from(cameraState:) in ElapsedTimeUiStateAdapter.kt to map VideoRecordingState.Active.Paused so the paused state isn't lost.

Once that's added, we can prepend the "PAUSED" text here conditionally based on that state.

Box(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we include a UI test file for this composable (e.g., ElapsedTimeTextTest.kt) to run basic accessibility checks, similar to the baseline established for the capture button in PR #461?

Running composeTestRule.onRoot().tryPerformAccessibilityChecks() would be great to lock in the contrast ratio of the white text against the new #ED0000 background to ensure it always passes ATF guidelines.

Additionally, we should improve the screen reader experience. Right now, TalkBack will read the literal text formatting (like "0 colon 30"). We could add a clearAndSetSemantics { contentDescription = ... } modifier here to override that and provide a more meaningful announcement like "Recording time: 0 minutes and 30 seconds" (or "Recording paused..."). We could then add a quick assertion in the test using assertContentDescriptionEquals(). What do you think?

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 = formattedTime,
textAlign = TextAlign.Center,
color = Color.White,
style = MaterialTheme.typography.labelLarge.copy(
fontWeight = FontWeight.Bold,
fontFeatureSettings = "tnum",
letterSpacing = 0.sp
)
)
)
}
}
}

Expand Down Expand Up @@ -950,3 +973,51 @@ private fun FocusMeteringIndicator(
}
}
}

@Preview(name = "Elapsed Time", showBackground = true, backgroundColor = 0xFF000000)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Once we add the paused state to ElapsedTimeUiState to support the "PAUSED" text prefix, let's also add a preview scenario for it here (e.g. PAUSED 0:30) to ensure it matches the mocks.

@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 5: Unavailable state (renders nothing, verifying the if-condition)
ElapsedTimeText(
elapsedTimeUiStateProvider = {
ElapsedTimeUiState.Unavailable
}
)
}
}
}
Loading