diff --git a/.gemini/styleguide.md b/.gemini/styleguide.md index e2bf5e9ae..53fbaa70c 100644 --- a/.gemini/styleguide.md +++ b/.gemini/styleguide.md @@ -25,6 +25,7 @@ When reviewing a pull request, focus on the following key areas: * **Remove Unused Imports:** Check for and remove any unused import statements to maintain code cleanliness. * Look for potential null-safety issues, improper error handling, or resource leaks. * **Promote Reusability (DRY Principle):** Identify duplicated or highly similar blocks of code. If a pattern of logic is repeated—even with minor variations—suggest extracting it into a reusable function, composable, or helper class. + * **Avoid Magic Numbers:** Avoid scattering literal dimension values or scales directly in the layout code. Instead, group them into a `private object Tokens` at the top of the file if file-scoped, or in a separate `Dimensions.kt` or `Tokens.kt` file if shared across features. Use semantic naming (e.g., `SmallPadding`) rather than value-based naming (e.g., `Dp16`). 3. **Performance and Efficiency** * Scan for inefficient operations, especially within Composable functions (e.g., expensive calculations, improper state management leading to excessive recompositions). @@ -94,6 +95,11 @@ When reviewing a pull request, focus on the following key areas: * **Apply Proper Semantics:** When building custom UI components from the ground up (e.g., a custom button made of an `Icon` and a `Text`), apply the correct semantics to ensure they are accessible. * Use `semantics { role = Role.Button }` (or `Role.Checkbox`, etc.) to define the component's logical purpose for screen readers. * For components made of multiple parts that should be read as a single, coherent unit, use `semantics { mergeDescendants = true }`. This prevents screen readers from announcing inner elements (like an icon and its text label) as separate, unrelated items. + * **Explicit Focusability:** When building custom components that handle input manually via low-level gestures (e.g., using `pointerInput` or `detectTapGestures`) rather than `Modifier.clickable()`, they may not automatically become focusable. In such cases, explicitly add `Modifier.focusable()` to ensure they are reachable via keyboard navigation and analyzed by automated accessibility checks. + * **Content vs State Descriptions:** + * Use `contentDescription` to describe the **identity** or **action** of the component (e.g., "Capture Photo", "Start Video Recording"). + * Use `stateDescription` to describe the **current state** of the component (e.g., "Locked", "Selected"). + * **Avoid Redundancy:** Do not include state information or control type in `contentDescription` (e.g., avoid "Locked Video Button" or "Shutter Button"). Let the system announce role and state automatically. ## Rules for Providing Feedback * **Be Constructive:** Frame feedback as suggestions, not commands. Explain the reasoning ("why") behind each comment. diff --git a/core/camera/src/androidTest/java/com/google/jetpackcamera/core/camera/utils/OpenGLTestUtil.kt b/core/camera/src/androidTest/java/com/google/jetpackcamera/core/camera/utils/OpenGLTestUtil.kt index 3553b4c7e..30dc5e822 100644 --- a/core/camera/src/androidTest/java/com/google/jetpackcamera/core/camera/utils/OpenGLTestUtil.kt +++ b/core/camera/src/androidTest/java/com/google/jetpackcamera/core/camera/utils/OpenGLTestUtil.kt @@ -48,6 +48,7 @@ import kotlinx.coroutines.withContext * * This is useful for tests that require a valid surface provider but do not need to display the output. */ +@android.annotation.SuppressLint("Recycle") suspend fun SurfaceRequest.provideUpdatingSurface() { var isReleased = false val executor = Executors.newFixedThreadPool(1) diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt index eff65479a..b6c371d21 100644 --- a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt +++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt @@ -419,6 +419,7 @@ private fun ContentScreen( ) { @Composable { modifier: Modifier -> PreviewDisplay( + modifier = modifier, previewDisplayUiState = previewDisplayState.value, onFlipCamera = onFlipCamera, onTapToFocus = onTapToFocusLambda, @@ -453,6 +454,7 @@ private fun ContentScreen( action() } CaptureButton( + modifier = modifier, captureButtonUiState = captureButtonState.value, isQuickSettingsOpen = (quickSettingsUiState as? QuickSettingsUiState.Available) ?.quickSettingsIsOpen ?: false, diff --git a/gradle.properties b/gradle.properties index 15ab33aba..c6243cc14 100644 --- a/gradle.properties +++ b/gradle.properties @@ -44,4 +44,5 @@ android.nonFinalResIds=false android.experimental.testOptions.managedDevices.maxConcurrentDevices=1 android.experimental.testOptions.managedDevices.setupTimeoutMinutes=180 # Ensure we can run managed devices on servers that don't support hardware rendering -android.testoptions.manageddevices.emulator.gpu=swiftshader_indirect \ No newline at end of file +android.testoptions.manageddevices.emulator.gpu=swiftshader_indirect +android.experimental.enableScreenshotTest=true \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a2317d598..c50b7b85d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,7 +15,10 @@ accompanist = "0.37.3" # See https://developer.android.com/jetpack/androidx/releases/compose-kotlin kotlinPlugin = "2.2.0" androidGradlePlugin = "8.10.1" +protobufPlugin = "0.9.5" +composeScreenshot = "0.0.1-alpha14" +accessibilityTestFramework = "4.1.1" androidxActivityCompose = "1.10.1" androidxAppCompat = "1.7.1" @@ -66,6 +69,7 @@ androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "androidxDatastore" } androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidxTestEspresso" } +androidx-espresso-accessibility = { module = "androidx.test.espresso:espresso-accessibility", version.ref = "androidxTestEspresso" } androidx-graphics-core = { module = "androidx.graphics:graphics-core", version.ref = "androidxGraphicsCore" } androidx-junit = { module = "androidx.test.ext:junit", version.ref = "androidxTestJunit" } androidx-lifecycle-livedata = { module = "androidx.lifecycle:lifecycle-livedata-ktx", version.ref = "androidxLifecycle" } @@ -88,8 +92,11 @@ camera-video = { module = "androidx.camera:camera-video", version.ref = "android camera-compose = { module = "androidx.camera:camera-compose", version.ref = "androidxCamera" } compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" } compose-junit = { module = "androidx.compose.ui:ui-test-junit4" } +compose-accessibility = { module = "androidx.compose.ui:ui-test-junit4-accessibility" } +accessibility-test-framework = { module = "com.google.android.apps.common.testing.accessibility.framework:accessibility-test-framework", version.ref = "accessibilityTestFramework" } compose-material3 = { module = "androidx.compose.material3:material3", version.ref = "composeMaterial" } compose-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" } +screenshot-validation-api = { module = "com.android.tools.screenshot:screenshot-validation-api", version.ref = "composeScreenshot" } compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } dagger-hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } @@ -124,3 +131,4 @@ dagger-hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "hi kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlinPlugin" } kotlin-kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlinPlugin" } +compose-screenshot = { id = "com.android.compose.screenshot", version.ref = "composeScreenshot" } diff --git a/ui/components/capture/build.gradle.kts b/ui/components/capture/build.gradle.kts index 08498a329..ac3f8a572 100644 --- a/ui/components/capture/build.gradle.kts +++ b/ui/components/capture/build.gradle.kts @@ -19,12 +19,15 @@ plugins { alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.kapt) alias(libs.plugins.compose.compiler) + alias(libs.plugins.compose.screenshot) } android { namespace = "com.google.jetpackcamera.ui.components.capture" compileSdk = libs.versions.compileSdk.get().toInt() + experimentalProperties["android.experimental.enableScreenshotTest"] = true + defaultConfig { minSdk = libs.versions.minSdk.get().toInt() testOptions.targetSdk = libs.versions.targetSdk.get().toInt() @@ -71,6 +74,9 @@ dependencies { val composeBom = platform(libs.compose.bom) implementation(composeBom) + // AndroidX Core KTX + implementation(libs.androidx.core.ktx) + // Accompanist - Permissions implementation(libs.accompanist.permissions) @@ -81,9 +87,6 @@ dependencies { implementation(libs.compose.ui.tooling.preview) debugImplementation(libs.compose.ui.tooling) - // Compose - Integration with ViewModels with Navigation and Hilt - implementation(libs.hilt.navigation.compose) - // CameraX implementation(libs.camera.core) implementation(libs.camera.compose) @@ -94,6 +97,8 @@ dependencies { // noinspection TestManifestGradleConfiguration: required for release build unit tests testImplementation(libs.compose.test.manifest) testImplementation(libs.compose.junit) + screenshotTestImplementation(libs.screenshot.validation.api) + screenshotTestImplementation(libs.compose.ui.tooling) // Testing testImplementation(libs.junit) @@ -104,6 +109,9 @@ dependencies { testImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(libs.androidx.espresso.accessibility) + androidTestImplementation(libs.compose.accessibility) + androidTestImplementation(libs.accessibility.test.framework) implementation(project(":ui:uistate")) implementation(project(":ui:uistate:capture")) @@ -123,3 +131,10 @@ dependencies { kapt { correctErrorTypes = true } +configurations.all { + resolutionStrategy { + // Exclude protobuf-lite to prevent DuplicateClassException conflicts with protobuf-javalite + // that is brought in by androidx.datastore, since the accessibility-test-framework brings in protobuf-lite. + exclude(group = "com.google.protobuf", module = "protobuf-lite") + } +} diff --git a/ui/components/capture/src/androidTest/java/com/google/jetpackcamera/ui/components/capture/CaptureButtonTest.kt b/ui/components/capture/src/androidTest/java/com/google/jetpackcamera/ui/components/capture/CaptureButtonTest.kt new file mode 100644 index 000000000..866547906 --- /dev/null +++ b/ui/components/capture/src/androidTest/java/com/google/jetpackcamera/ui/components/capture/CaptureButtonTest.kt @@ -0,0 +1,189 @@ +/* + * 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.activity.ComponentActivity +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertContentDescriptionEquals +import androidx.compose.ui.test.isNotEnabled +import androidx.compose.ui.test.junit4.accessibility.enableAccessibilityChecks +import androidx.compose.ui.test.junit4.createAndroidComposeRule +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.android.apps.common.testing.accessibility.framework.AccessibilityCheckResult.AccessibilityCheckResultType +import com.google.android.apps.common.testing.accessibility.framework.integrations.espresso.AccessibilityValidator +import com.google.jetpackcamera.model.CaptureMode +import com.google.jetpackcamera.ui.uistate.capture.CaptureButtonUiState +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class CaptureButtonTest { + @get:Rule + val composeTestRule = createAndroidComposeRule() + + @Before + fun setUp() { + composeTestRule.enableAccessibilityChecks( + AccessibilityValidator().setRunChecksFromRootView(true).also { + it.setThrowExceptionFor(AccessibilityCheckResultType.ERROR) + } + ) + } + + @Test + fun captureButton_standard_exists() { + composeTestRule.setContent { + CaptureButton( + modifier = Modifier.testTag("CaptureButtonTestTag"), + onImageCapture = {}, + onStartRecording = {}, + onStopRecording = {}, + onLockVideoRecording = {}, + onIncrementZoom = {}, + captureButtonUiState = CaptureButtonUiState.Enabled.Idle(CaptureMode.STANDARD) + ) + } + + composeTestRule.onNodeWithTag("CaptureButtonTestTag").assertExists() + composeTestRule.onNodeWithTag( + "CaptureButtonTestTag" + ).assertContentDescriptionEquals("Capture Photo") + composeTestRule.onNodeWithTag("CaptureButtonTestTag", useUnmergedTree = true) + .assert(SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Button)) + composeTestRule.onRoot().tryPerformAccessibilityChecks() + } + + @Test + fun captureButton_imageOnly_exists() { + composeTestRule.setContent { + CaptureButton( + modifier = Modifier.testTag("CaptureButtonImageOnly"), + onImageCapture = {}, + onStartRecording = {}, + onStopRecording = {}, + onLockVideoRecording = {}, + onIncrementZoom = {}, + captureButtonUiState = CaptureButtonUiState.Enabled.Idle(CaptureMode.IMAGE_ONLY) + ) + } + composeTestRule.onRoot().tryPerformAccessibilityChecks() + composeTestRule.onNodeWithTag("CaptureButtonImageOnly").assertExists() + composeTestRule.onNodeWithTag( + "CaptureButtonImageOnly" + ).assertContentDescriptionEquals("Capture Photo") + composeTestRule.onNodeWithTag("CaptureButtonImageOnly", useUnmergedTree = true) + .assert(SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Button)) + } + + @Test + fun captureButton_videoOnly_exists() { + composeTestRule.setContent { + CaptureButton( + modifier = Modifier.testTag("CaptureButtonVideoOnly"), + onImageCapture = {}, + onStartRecording = {}, + onStopRecording = {}, + onLockVideoRecording = {}, + onIncrementZoom = {}, + captureButtonUiState = CaptureButtonUiState.Enabled.Idle(CaptureMode.VIDEO_ONLY) + ) + } + composeTestRule.onRoot().tryPerformAccessibilityChecks() + composeTestRule.onNodeWithTag("CaptureButtonVideoOnly").assertExists() + composeTestRule.onNodeWithTag( + "CaptureButtonVideoOnly" + ).assertContentDescriptionEquals("Start Video Recording") + composeTestRule.onNodeWithTag("CaptureButtonVideoOnly", useUnmergedTree = true) + .assert(SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Button)) + } + + @Test + fun captureButton_lockedRecording_exists() { + composeTestRule.setContent { + CaptureButton( + modifier = Modifier.testTag("CaptureButtonLocked"), + onImageCapture = {}, + onStartRecording = {}, + onStopRecording = {}, + onLockVideoRecording = {}, + onIncrementZoom = {}, + captureButtonUiState = CaptureButtonUiState.Enabled.Recording.LockedRecording + ) + } + composeTestRule.onRoot().tryPerformAccessibilityChecks() + composeTestRule.onNodeWithTag("CaptureButtonLocked").assertExists() + composeTestRule.onNodeWithTag( + "CaptureButtonLocked" + ).assertContentDescriptionEquals("Stop Video Recording") + composeTestRule.onNodeWithTag("CaptureButtonLocked", useUnmergedTree = true) + .assert(SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Button)) + } + + @Test + fun captureButton_pressedRecording_exists() { + composeTestRule.setContent { + CaptureButton( + modifier = Modifier.testTag("CaptureButtonPressedRecording"), + onImageCapture = {}, + onStartRecording = {}, + onStopRecording = {}, + onLockVideoRecording = {}, + onIncrementZoom = {}, + captureButtonUiState = CaptureButtonUiState.Enabled.Recording.PressedRecording + ) + } + composeTestRule.onRoot().tryPerformAccessibilityChecks() + composeTestRule.onNodeWithTag("CaptureButtonPressedRecording").assertExists() + composeTestRule.onNodeWithTag( + "CaptureButtonPressedRecording" + ).assertContentDescriptionEquals("Recording Video") + composeTestRule.onNodeWithTag("CaptureButtonPressedRecording", useUnmergedTree = true) + .assert(SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Button)) + } + + @Test + fun captureButton_disabled_exists() { + composeTestRule.setContent { + CaptureButton( + modifier = Modifier.testTag("CaptureButtonDisabled"), + onImageCapture = {}, + onStartRecording = {}, + onStopRecording = {}, + onLockVideoRecording = {}, + onIncrementZoom = {}, + captureButtonUiState = CaptureButtonUiState.Enabled.Idle( + CaptureMode.STANDARD, + isEnabled = false + ) + ) + } + composeTestRule.onRoot().tryPerformAccessibilityChecks() + composeTestRule.onNodeWithTag("CaptureButtonDisabled").assertExists() + composeTestRule.onNodeWithTag( + "CaptureButtonDisabled" + ).assert(androidx.compose.ui.test.isNotEnabled()) + } +} diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureButtonComponents.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureButtonComponents.kt index 88ce0c69f..692c4115b 100644 --- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureButtonComponents.kt +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureButtonComponents.kt @@ -15,26 +15,28 @@ */ package com.google.jetpackcamera.ui.components.capture -import android.util.Log import android.view.KeyEvent import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition +import androidx.compose.animation.animateColor import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.animateDp import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.snap import androidx.compose.animation.core.tween +import androidx.compose.animation.core.updateTransition import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.scaleIn import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.border -import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.PressInteraction +import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset @@ -42,12 +44,17 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.State +import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf @@ -62,27 +69,50 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect -import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalViewConfiguration import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.disabled +import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.semantics import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.core.view.ViewCompat import com.google.jetpackcamera.model.CaptureMode import com.google.jetpackcamera.ui.uistate.capture.CaptureButtonUiState +import kotlin.math.roundToInt import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch private const val TAG = "CaptureButton" -private const val DEFAULT_CAPTURE_BUTTON_SIZE = 80f +private const val DEFAULT_CAPTURE_BUTTON_SIZE = 76f + +private const val IDLE_IMAGE_CAPTURE_SCALE = 0.86f +private const val IDLE_VIDEO_CAPTURE_SCALE = 0.64f +private const val PRESSED_IMAGE_CAPTURE_SCALE = 0.93f +private const val LOCKED_RECORDING_NUCLEUS_SCALE = 0.51f +private const val MORPH_INTERMEDIATE_SCALE = 0.58f +private const val BORDER_WIDTH = 3f +private const val ANIMATION_DURATION_SIZE = 250 +private const val ANIMATION_DURATION_COLOR = 150 +private const val ANIMATION_DURATION_NUCLEUS_RELEASE = 100 +private const val ANIMATION_DURATION_DISABLED = 500 +private const val ANIMATION_DURATION_NUCLEUS_PRESSED = 50 +private const val ALPHA_DISABLED_NUCLEUS = 0.6f +private const val ALPHA_WHITE_20 = 0.2f +private const val ALPHA_BLACK_60 = 0.6f + +private val LOCKED_CORNER_RADIUS = 8.dp // scales against the size of the capture button private const val LOCK_SWITCH_PRESSED_NUCLEUS_SCALE = .5f @@ -193,7 +223,8 @@ internal fun CaptureButton( onLockVideoRecording: (Boolean) -> Unit, onIncrementZoom: (Float) -> Unit, captureButtonUiState: CaptureButtonUiState, - captureButtonSize: Float = DEFAULT_CAPTURE_BUTTON_SIZE + captureButtonSize: Float = DEFAULT_CAPTURE_BUTTON_SIZE, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() } ) { val currentUiState = rememberUpdatedState(captureButtonUiState) val firstKeyPressed = remember { mutableStateOf(null) } @@ -202,6 +233,9 @@ internal fun CaptureButton( val scope = rememberCoroutineScope() val longPressTimeout = LocalViewConfiguration.current.longPressTimeoutMillis + // To handle press interactions from key events + var currentPressInteraction by remember { mutableStateOf(null) } + LaunchedEffect(captureButtonUiState) { if (captureButtonUiState is CaptureButtonUiState.Enabled.Idle) { onLockVideoRecording(false) @@ -220,7 +254,6 @@ internal fun CaptureButton( CaptureMode.STANDARD, CaptureMode.VIDEO_ONLY -> { isLongPressing.value = true - Log.d(TAG, "Starting recording") onStartRecording() } @@ -238,6 +271,16 @@ internal fun CaptureButton( if (!captureButtonUiState.isEnabled) return if (firstKeyPressed.value == null) { firstKeyPressed.value = captureSource + + // Emit press interaction for key events to trigger UI feedback + if (captureSource != CaptureSource.CAPTURE_BUTTON) { + val press = PressInteraction.Press(Offset.Zero) + currentPressInteraction = press + scope.launch { + interactionSource.emit(press) + } + } + longPressJob = scope.launch { delay(longPressTimeout) onLongPress() @@ -248,6 +291,18 @@ internal fun CaptureButton( fun onKeyUp(captureSource: CaptureSource, isLocked: Boolean = false) { // releasing while pressed recording if (firstKeyPressed.value == captureSource) { + // Emit release interaction for key events + if (captureSource != CaptureSource.CAPTURE_BUTTON) { + val interactionToRelease = currentPressInteraction + currentPressInteraction = null + if (interactionToRelease != null) { + scope.launch { + delay(50) // Ensure visible press state for fast taps + interactionSource.emit(PressInteraction.Release(interactionToRelease)) + } + } + } + if (isLongPressing.value) { if (!isLocked && ( @@ -257,7 +312,6 @@ internal fun CaptureButton( CaptureButtonUiState.Enabled.Recording.Starting ) ) { - Log.d(TAG, "Stopping recording") onStopRecording() } } @@ -270,7 +324,6 @@ internal fun CaptureButton( CaptureMode.VIDEO_ONLY -> { onLockVideoRecording(true) - Log.d(TAG, "Starting recording") onStartRecording() } } @@ -300,7 +353,8 @@ internal fun CaptureButton( onLockVideoRecording = onLockVideoRecording, onDragZoom = onIncrementZoom, captureButtonUiState = captureButtonUiState, - captureButtonSize = captureButtonSize + captureButtonSize = captureButtonSize, + interactionSource = interactionSource ) } @@ -344,9 +398,13 @@ private fun CaptureButton( onLockVideoRecording: (Boolean) -> Unit, captureButtonUiState: CaptureButtonUiState, useLockSwitch: Boolean = true, - captureButtonSize: Float = DEFAULT_CAPTURE_BUTTON_SIZE + captureButtonSize: Float = DEFAULT_CAPTURE_BUTTON_SIZE, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() } ) { - // todo: explore MutableInteractionSource + val initialPressed = LocalInitialPressedState.current + var isCaptureButtonPressed by remember(initialPressed) { + mutableStateOf(initialPressed) + } var isTapping by remember { mutableStateOf(false) } @@ -365,18 +423,32 @@ private fun CaptureButton( ) val disableAnimations = LocalDisableAnimations.current - + val isPressedInteraction by interactionSource.collectIsPressedAsState() val animatedColor by animateColorAsState( - targetValue = if (isVisuallyDisabled) { - LocalContentColor.current.copy(alpha = 0.38f) - } else { - LocalContentColor.current + targetValue = when { + isVisuallyDisabled -> { + if (currentUiState.value.let { + it is CaptureButtonUiState.Enabled.Idle && + it.captureMode == CaptureMode.STANDARD + } + ) { + LocalContentColor.current.copy(alpha = 0.2f) + } else { + Color.Transparent + } + } + currentUiState.value.let { + it is CaptureButtonUiState.Enabled.Idle && + it.captureMode == CaptureMode.STANDARD + } -> LocalContentColor.current + else -> Color.Transparent }, animationSpec = if (disableAnimations) { snap() } else { tween( - durationMillis = if (isVisuallyDisabled) 1000 else 300 + durationMillis = + if (isVisuallyDisabled) ANIMATION_DURATION_DISABLED else ANIMATION_DURATION_COLOR ) }, label = "Capture Button Color" @@ -421,19 +493,25 @@ private fun CaptureButton( onLongPress = {}, onPress = { isTapping = true + val press = PressInteraction.Press(it) + interactionSource.emit(press) + isCaptureButtonPressed = true // Manually set pressed state try { onPress() awaitRelease() } finally { isTapping = false + isCaptureButtonPressed = false // Manually unset pressed state + interactionSource.emit(PressInteraction.Release(press)) } if (shouldBeLocked()) { onLockVideoRecording(true) onRelease(true) + } else { + onRelease(false) } switchPosition = LOCK_SWITCH_POSITION_OFF - onRelease(false) } ) } @@ -481,6 +559,12 @@ private fun CaptureButton( } else { Modifier } + val capturePhotoDesc = stringResource(R.string.capture_button_capture_photo) + val startVideoDesc = stringResource(R.string.capture_button_start_video_recording) + val recordingVideoDesc = stringResource(R.string.capture_button_recording_video) + val stopVideoDesc = stringResource(R.string.capture_button_stop_video_recording) + val unavailableDesc = stringResource(R.string.capture_button_unavailable) + CaptureButtonRing( modifier = modifier .onSizeChanged { @@ -488,10 +572,23 @@ private fun CaptureButton( Rect(0f, 0f, it.width.toFloat(), it.height.toFloat()) } .semantics { + role = Role.Button if (!captureButtonUiState.isEnabled) { disabled() } + contentDescription = when (val uiState = captureButtonUiState) { + is CaptureButtonUiState.Enabled.Idle -> when (uiState.captureMode) { + CaptureMode.STANDARD -> capturePhotoDesc + CaptureMode.IMAGE_ONLY -> capturePhotoDesc + CaptureMode.VIDEO_ONLY -> startVideoDesc + } + CaptureButtonUiState.Enabled.Recording.PressedRecording -> recordingVideoDesc + CaptureButtonUiState.Enabled.Recording.LockedRecording -> stopVideoDesc + CaptureButtonUiState.Unavailable -> unavailableDesc + CaptureButtonUiState.Enabled.Recording.Starting -> startVideoDesc + } } + .focusable() .then(gestureModifier), captureButtonSize = captureButtonSize, color = animatedColor @@ -501,29 +598,55 @@ private fun CaptureButton( captureButtonUiState = captureButtonUiState, captureButtonSize = captureButtonSize, switchWidth = switchWidth.dp, - switchPosition = switchPosition, + switchPositionProvider = { switchPosition }, onToggleSwitchPosition = { toggleSwitchPosition() }, - shouldBeLocked = { shouldBeLocked() } + shouldBeLocked = { shouldBeLocked() }, + isVisuallyDisabled = isVisuallyDisabled, + isTapping = isCaptureButtonPressed || isPressedInteraction ) } else { CaptureButtonNucleus( captureButtonUiState = captureButtonUiState, - isTapping = isTapping, - captureButtonSize = captureButtonSize + isTapping = isCaptureButtonPressed || isPressedInteraction, + captureButtonSize = captureButtonSize, + isVisuallyDisabled = isVisuallyDisabled ) } } } +/** + * CompositionLocal to provide the initial pressed state of the capture button. + * This is primarily used for previews and screenshot tests to force the pressed state. + */ +internal val LocalInitialPressedState = compositionLocalOf { false } + @Composable -private fun CaptureButtonRing( +internal fun CaptureButtonRing( modifier: Modifier = Modifier, captureButtonSize: Float, color: Color, - borderWidth: Float = 4f, + borderWidth: Float = BORDER_WIDTH, contents: (@Composable () -> Unit)? = null ) { + val backgroundStyle = LocalCameraControlBackgroundStyle.current + val targetBackgroundColor = when (backgroundStyle) { + CameraControlBackgroundStyle.WHITE_20 -> Color.White.copy(alpha = ALPHA_WHITE_20) + CameraControlBackgroundStyle.BLACK_60 -> Color.Black.copy(alpha = ALPHA_BLACK_60) + } + val backgroundColor by animateColorAsState( + targetValue = targetBackgroundColor, + animationSpec = androidx.compose.animation.core.tween( + durationMillis = ANIMATION_DURATION_COLOR + ), + label = "backgroundColor" + ) Box(modifier = modifier, contentAlignment = Alignment.Center) { + Box( + modifier = Modifier + .size(captureButtonSize.dp) + .background(backgroundColor, CircleShape) + ) contents?.invoke() // todo(): use a canvas instead of a box. // the sizing gets funny so the scales need to be completely readjusted @@ -546,13 +669,16 @@ private fun LockSwitchCaptureButtonNucleus( captureButtonUiState: CaptureButtonUiState, captureButtonSize: Float, switchWidth: Dp, - switchPosition: Float, + switchPositionProvider: () -> Float, onToggleSwitchPosition: () -> Unit, - shouldBeLocked: () -> Boolean + shouldBeLocked: () -> Boolean, + isVisuallyDisabled: Boolean = false, + isTapping: Boolean ) { val pressedNucleusSize = (captureButtonSize * LOCK_SWITCH_PRESSED_NUCLEUS_SCALE).dp val switchHeight = (pressedNucleusSize * LOCK_SWITCH_HEIGHT_SCALE) val disableAnimations = LocalDisableAnimations.current + val lockVideoRecordingDesc = stringResource(R.string.capture_button_lock_video_recording) Box( modifier = modifier @@ -563,7 +689,7 @@ private fun LockSwitchCaptureButtonNucleus( Box( contentAlignment = Alignment.CenterStart, modifier = Modifier - .width(switchWidth) + .width(switchWidth + 4.dp) .height(switchHeight) .offset(x = -(switchWidth - pressedNucleusSize) / 2) ) { @@ -593,11 +719,20 @@ private fun LockSwitchCaptureButtonNucleus( // is behind lock icon but in front of the switch background CaptureButtonNucleus( - offsetX = (-(switchWidth - pressedNucleusSize) * switchPosition), + modifier = Modifier.offset { + IntOffset( + x = ( + -(switchWidth - pressedNucleusSize).toPx() * + switchPositionProvider() + ).roundToInt(), + y = 0 + ) + }, captureButtonSize = captureButtonSize, captureButtonUiState = captureButtonUiState, pressedVideoCaptureScale = LOCK_SWITCH_PRESSED_NUCLEUS_SCALE, - isTapping = false + isTapping = isTapping, + isVisuallyDisabled = isVisuallyDisabled ) // locked icon, matches cylinder offset @@ -607,52 +742,64 @@ private fun LockSwitchCaptureButtonNucleus( enter = if (disableAnimations) EnterTransition.None else fadeIn(), exit = ExitTransition.None ) { - Icon( + Box( modifier = Modifier - .size(switchHeight * .75f) .align(Alignment.CenterStart) .padding(start = 8.dp) .offset(x = -(switchWidth - pressedNucleusSize)) - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() } - ) { - onToggleSwitchPosition() + .size(32.dp) + .semantics { + contentDescription = lockVideoRecordingDesc + role = Role.Button + } + .pointerInput(Unit) { + detectTapGestures { + onToggleSwitchPosition() + } + } + ) { + Icon( + modifier = Modifier.size(switchHeight * .75f).align(Alignment.Center), + tint = Color.White, + painter = if (shouldBeLocked()) { + painterResource(R.drawable.ic_lock) + } else { + painterResource(R.drawable.ic_lock_open) }, - tint = Color.White, - painter = if (shouldBeLocked()) { - painterResource(R.drawable.ic_lock) - } else { - painterResource(R.drawable.ic_lock_open) - }, - contentDescription = null - ) + contentDescription = null + ) + } } } } +private enum class NucleusState { + Disabled, + Idle, + Pressed +} + /** * The animated center of the capture button. It serves as a visual indicator of the current capture and recording states. * * @param captureButtonSize diameter of the capture button ring that this is scaled to - * @param isPressed true if the capture button is physically pressed on - * @param offsetX the offset of this component. 0 by default + * @param isTapping true if the capture button is physically pressed on * @param idleImageCaptureScale the scale factor for the idle size of the image-only nucleus. Must be between 0 and 1. * @param idleVideoCaptureScale the scale factor for the idle size of the video-only nucleus. Must be between 0 and 1. * @param pressedVideoCaptureScale the scale factor for the pressed size of the video-only nucleus. Must be between 0 and 1. */ @Composable -private fun CaptureButtonNucleus( +internal fun CaptureButtonNucleus( modifier: Modifier = Modifier, captureButtonUiState: CaptureButtonUiState, isTapping: Boolean, captureButtonSize: Float, - offsetX: Dp = 0.dp, recordingColor: Color = Color.Red, imageCaptureModeColor: Color = Color.White, - idleImageCaptureScale: Float = .7f, - idleVideoCaptureScale: Float = .35f, - pressedVideoCaptureScale: Float = .7f + idleImageCaptureScale: Float = IDLE_IMAGE_CAPTURE_SCALE, + idleVideoCaptureScale: Float = IDLE_VIDEO_CAPTURE_SCALE, + pressedVideoCaptureScale: Float = IDLE_IMAGE_CAPTURE_SCALE, + isVisuallyDisabled: Boolean = false ) { require(idleImageCaptureScale in 0f..1f) { "value must be between 0 and 1 to remain within the bounds of the capture button" @@ -668,10 +815,11 @@ private fun CaptureButtonNucleus( val disableAnimations = LocalDisableAnimations.current // smoothly animate between the size changes of the capture button center - val centerShapeSize by animateDpAsState( + val standardShapeSize by animateDpAsState( targetValue = when (val uiState = currentUiState.value) { - // inner circle fills white ring when locked - CaptureButtonUiState.Enabled.Recording.LockedRecording -> captureButtonSize.dp + // inner circle becomes a square when locked + CaptureButtonUiState.Enabled.Recording.LockedRecording -> + (captureButtonSize * LOCKED_RECORDING_NUCLEUS_SCALE).dp CaptureButtonUiState.Enabled.Recording.Starting, CaptureButtonUiState.Enabled.Recording.PressedRecording -> @@ -690,34 +838,116 @@ private fun CaptureButtonNucleus( animationSpec = if (disableAnimations) { snap() } else { - tween(durationMillis = 500, easing = FastOutSlowInEasing) + tween(durationMillis = ANIMATION_DURATION_SIZE, easing = FastOutSlowInEasing) } ) + val pressTransition = updateTransition( + targetState = isTapping && + currentUiState.value.let { + it is CaptureButtonUiState.Enabled.Idle && + ( + it.captureMode == CaptureMode.IMAGE_ONLY || + it.captureMode == CaptureMode.STANDARD + ) + }, + label = "Press Size Transition" + ) + + val centerShapeSize by pressTransition.animateDp( + transitionSpec = { + if (targetState) { + snap() + } else { + tween(durationMillis = ANIMATION_DURATION_NUCLEUS_RELEASE) + } + }, + label = "Nucleus Size" + ) { isPressedImage -> + if (isPressedImage) { + (captureButtonSize * PRESSED_IMAGE_CAPTURE_SCALE).dp + } else { + standardShapeSize + } + } + + val sizeFinal = (captureButtonSize * LOCKED_RECORDING_NUCLEUS_SCALE).dp + val sizeInter = (captureButtonSize * MORPH_INTERMEDIATE_SCALE).dp + val isLocked = currentUiState.value is CaptureButtonUiState.Enabled.Recording.LockedRecording + val cornerRadius = if (isLocked) { + if (centerShapeSize <= sizeInter) { + val fraction = (centerShapeSize - sizeFinal) / (sizeInter - sizeFinal) + val coercedFraction = fraction.coerceIn(0f, 1f) + LOCKED_CORNER_RADIUS + (centerShapeSize / 2 - LOCKED_CORNER_RADIUS) * coercedFraction + } else { + centerShapeSize / 2 + } + } else { + centerShapeSize / 2 + } + // used to fade between red/white in the center of the capture button - val animatedColor by animateColorAsState( - targetValue = when (val uiState = currentUiState.value) { - is CaptureButtonUiState.Enabled.Idle -> when (uiState.captureMode) { - CaptureMode.STANDARD -> imageCaptureModeColor - CaptureMode.IMAGE_ONLY -> imageCaptureModeColor - CaptureMode.VIDEO_ONLY -> recordingColor + val isPressableImageMode = currentUiState.value.let { + it is CaptureButtonUiState.Enabled.Idle && + (it.captureMode == CaptureMode.IMAGE_ONLY || it.captureMode == CaptureMode.STANDARD) + } + val nucleusState = when { + isVisuallyDisabled -> NucleusState.Disabled + isTapping && isPressableImageMode -> NucleusState.Pressed + else -> NucleusState.Idle + } + + val transition = + updateTransition(targetState = nucleusState, label = "Nucleus Color Transition") + val animatedColor by transition.animateColor( + label = "Nucleus Color", + transitionSpec = { + if (disableAnimations) { + snap() + } else { + when { + NucleusState.Disabled isTransitioningTo NucleusState.Idle -> tween( + durationMillis = ANIMATION_DURATION_COLOR + ) + NucleusState.Idle isTransitioningTo NucleusState.Disabled -> tween( + durationMillis = ANIMATION_DURATION_DISABLED + ) + NucleusState.Pressed isTransitioningTo NucleusState.Idle -> tween( + durationMillis = ANIMATION_DURATION_NUCLEUS_PRESSED + ) + else -> snap() + } } + } + ) { state -> + when (state) { + NucleusState.Disabled -> Color.Black.copy(alpha = ALPHA_DISABLED_NUCLEUS) + NucleusState.Pressed -> imageCaptureModeColor + NucleusState.Idle -> { + when (val uiState = currentUiState.value) { + is CaptureButtonUiState.Enabled.Idle -> when (uiState.captureMode) { + CaptureMode.STANDARD -> imageCaptureModeColor + CaptureMode.IMAGE_ONLY -> imageCaptureModeColor + CaptureMode.VIDEO_ONLY -> + if (isTapping) recordingColor else imageCaptureModeColor + } - is CaptureButtonUiState.Enabled.Recording -> recordingColor - is CaptureButtonUiState.Unavailable -> Color.Transparent - }, - animationSpec = if (disableAnimations) snap() else tween(durationMillis = 500) - ) + is CaptureButtonUiState.Enabled.Recording -> recordingColor + is CaptureButtonUiState.Unavailable -> Color.Transparent + } + } + } + } // this box contains and centers everything - Box(modifier = modifier.offset(x = offsetX), contentAlignment = Alignment.Center) { + Box(modifier = modifier, contentAlignment = Alignment.Center) { // this box is the inner circle Box(modifier = Modifier) { Box( contentAlignment = Alignment.Center, modifier = Modifier .size(centerShapeSize) - .clip(CircleShape) + .clip(RoundedCornerShape(cornerRadius)) .alpha( if (isTapping && currentUiState.value == @@ -731,25 +961,38 @@ private fun CaptureButtonNucleus( .background(animatedColor) ) {} } - // central "square" stop icon - val isVisible = currentUiState.value is - CaptureButtonUiState.Enabled.Recording.LockedRecording - AnimatedVisibility( - visible = isVisible, - enter = if (disableAnimations) { - EnterTransition.None - } else { - scaleIn(initialScale = .5f) + fadeIn() - }, - exit = if (disableAnimations) ExitTransition.None else fadeOut() + } +} + +@Composable +internal fun PreviewCaptureButton( + captureButtonUiState: CaptureButtonUiState, + modifier: Modifier = Modifier, + contentAlignment: Alignment = Alignment.CenterEnd, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() } +) { + MaterialTheme(colorScheme = darkColorScheme()) { + CompositionLocalProvider( + LocalContentColor provides Color.White ) { - val smallBoxSize = (captureButtonSize / 5f).dp - Canvas(modifier = Modifier) { - drawRoundRect( - color = Color.White, - topLeft = Offset(-smallBoxSize.toPx() / 2f, -smallBoxSize.toPx() / 2f), - size = Size(smallBoxSize.toPx(), smallBoxSize.toPx()), - cornerRadius = CornerRadius(smallBoxSize.toPx() * .15f) + Box( + modifier = modifier + .background( + Brush.verticalGradient( + colors = listOf(Color.Gray, Color.DarkGray) + ) + ), + contentAlignment = contentAlignment + ) { + CaptureButton( + modifier = Modifier, + onImageCapture = {}, + onStartRecording = {}, + onStopRecording = {}, + onLockVideoRecording = {}, + onIncrementZoom = {}, + captureButtonUiState = captureButtonUiState, + interactionSource = interactionSource ) } } @@ -758,177 +1001,124 @@ private fun CaptureButtonNucleus( @Preview @Composable -private fun CaptureButtonUnavailablePreview() { - CaptureButton( - onImageCapture = {}, - onStartRecording = {}, - onStopRecording = {}, - onLockVideoRecording = {}, - onIncrementZoom = {}, +internal fun CaptureButtonUnavailablePreview() { + PreviewCaptureButton( captureButtonUiState = CaptureButtonUiState.Unavailable ) } @Preview @Composable -private fun IdleStandardCaptureButtonPreview() { - CaptureButtonRing(captureButtonSize = DEFAULT_CAPTURE_BUTTON_SIZE, color = Color.White) { - CaptureButtonNucleus( - captureButtonUiState = CaptureButtonUiState.Enabled.Idle(CaptureMode.STANDARD), - isTapping = false, - captureButtonSize = DEFAULT_CAPTURE_BUTTON_SIZE - ) - } -} - -@Preview -@Composable -private fun IdleImageCaptureButtonPreview() { - CaptureButtonRing(captureButtonSize = DEFAULT_CAPTURE_BUTTON_SIZE, color = Color.White) { - CaptureButtonNucleus( - captureButtonUiState = CaptureButtonUiState.Enabled.Idle(CaptureMode.IMAGE_ONLY), - isTapping = false, - captureButtonSize = DEFAULT_CAPTURE_BUTTON_SIZE - ) - } -} - -@Preview -@Composable -private fun IdleVideoOnlyCaptureButtonPreview() { - CaptureButtonRing(captureButtonSize = DEFAULT_CAPTURE_BUTTON_SIZE, color = Color.White) { - CaptureButtonNucleus( - captureButtonUiState = CaptureButtonUiState.Enabled.Idle(CaptureMode.VIDEO_ONLY), - isTapping = false, - captureButtonSize = DEFAULT_CAPTURE_BUTTON_SIZE - ) - } +internal fun IdleStandardCaptureButtonPreview() { + PreviewCaptureButton( + captureButtonUiState = CaptureButtonUiState.Enabled.Idle(CaptureMode.STANDARD) + ) } @Preview @Composable -private fun IdleStandardCaptureButtonDisabledPreview() { - CaptureButtonRing(captureButtonSize = DEFAULT_CAPTURE_BUTTON_SIZE, color = Color.Gray) { - CaptureButtonNucleus( - captureButtonUiState = CaptureButtonUiState.Enabled.Idle( - CaptureMode.STANDARD, - isEnabled = false - ), - isTapping = false, - captureButtonSize = DEFAULT_CAPTURE_BUTTON_SIZE - ) - } +internal fun IdleImageCaptureButtonPreview() { + PreviewCaptureButton( + captureButtonUiState = CaptureButtonUiState.Enabled.Idle(CaptureMode.IMAGE_ONLY) + ) } @Preview @Composable -private fun IdleImageCaptureButtonDisabledPreview() { - CaptureButtonRing(captureButtonSize = DEFAULT_CAPTURE_BUTTON_SIZE, color = Color.Gray) { - CaptureButtonNucleus( - captureButtonUiState = CaptureButtonUiState.Enabled.Idle( - CaptureMode.IMAGE_ONLY, - isEnabled = false - ), - isTapping = false, - captureButtonSize = DEFAULT_CAPTURE_BUTTON_SIZE - ) - } +internal fun IdleVideoOnlyCaptureButtonPreview() { + PreviewCaptureButton( + captureButtonUiState = CaptureButtonUiState.Enabled.Idle(CaptureMode.VIDEO_ONLY) + ) } @Preview @Composable -private fun IdleVideoOnlyCaptureButtonDisabledPreview() { - CaptureButtonRing(captureButtonSize = DEFAULT_CAPTURE_BUTTON_SIZE, color = Color.Gray) { - CaptureButtonNucleus( - captureButtonUiState = CaptureButtonUiState.Enabled.Idle( - CaptureMode.VIDEO_ONLY, - isEnabled = false - ), - isTapping = false, - captureButtonSize = DEFAULT_CAPTURE_BUTTON_SIZE +internal fun IdleStandardCaptureButtonDisabledPreview() { + PreviewCaptureButton( + captureButtonUiState = CaptureButtonUiState.Enabled.Idle( + CaptureMode.STANDARD, + isEnabled = false ) - } + ) } @Preview @Composable -private fun PressedImageCaptureButtonPreview() { - CaptureButtonRing(captureButtonSize = DEFAULT_CAPTURE_BUTTON_SIZE, color = Color.White) { - CaptureButtonNucleus( - captureButtonUiState = CaptureButtonUiState.Enabled.Idle(CaptureMode.IMAGE_ONLY), - isTapping = true, - captureButtonSize = DEFAULT_CAPTURE_BUTTON_SIZE +internal fun IdleImageCaptureButtonDisabledPreview() { + PreviewCaptureButton( + captureButtonUiState = CaptureButtonUiState.Enabled.Idle( + CaptureMode.IMAGE_ONLY, + isEnabled = false ) - } + ) } @Preview @Composable -private fun IdleRecordingCaptureButtonPreview() { - CaptureButtonRing(captureButtonSize = DEFAULT_CAPTURE_BUTTON_SIZE, color = Color.White) { - CaptureButtonNucleus( - captureButtonUiState = CaptureButtonUiState.Enabled.Idle(CaptureMode.VIDEO_ONLY), - isTapping = false, - captureButtonSize = DEFAULT_CAPTURE_BUTTON_SIZE +internal fun IdleVideoOnlyCaptureButtonDisabledPreview() { + PreviewCaptureButton( + captureButtonUiState = CaptureButtonUiState.Enabled.Idle( + CaptureMode.VIDEO_ONLY, + isEnabled = false ) - } + ) } @Preview @Composable -private fun SimpleNucleusPressedRecordingPreview() { - CaptureButtonRing(captureButtonSize = DEFAULT_CAPTURE_BUTTON_SIZE, color = Color.White) { - CaptureButtonNucleus( - captureButtonUiState = CaptureButtonUiState.Enabled.Recording.PressedRecording, - isTapping = true, - captureButtonSize = DEFAULT_CAPTURE_BUTTON_SIZE +internal fun PressedImageCaptureButtonPreview() { + CompositionLocalProvider(LocalInitialPressedState provides true) { + PreviewCaptureButton( + captureButtonUiState = CaptureButtonUiState.Enabled.Idle(CaptureMode.IMAGE_ONLY) ) } } @Preview @Composable -private fun LockedRecordingPreview() { - CaptureButtonRing(captureButtonSize = DEFAULT_CAPTURE_BUTTON_SIZE, color = Color.White) { - CaptureButtonNucleus( - captureButtonUiState = CaptureButtonUiState.Enabled.Recording.LockedRecording, - isTapping = false, - captureButtonSize = DEFAULT_CAPTURE_BUTTON_SIZE - ) - } +internal fun LockSwitchUnlockedPressedRecordingPreview() { + // box is here to account for the offset lock switch + PreviewCaptureButton( + captureButtonUiState = CaptureButtonUiState.Enabled.Recording.PressedRecording, + modifier = Modifier.width(150.dp), + contentAlignment = Alignment.CenterEnd + ) } @Preview @Composable -private fun LockSwitchUnlockedPressedRecordingPreview() { - // box is here to account for the offset lock switch - Box(modifier = Modifier.width(150.dp), contentAlignment = Alignment.CenterEnd) { - CaptureButtonRing(captureButtonSize = DEFAULT_CAPTURE_BUTTON_SIZE, color = Color.White) { - LockSwitchCaptureButtonNucleus( - captureButtonSize = DEFAULT_CAPTURE_BUTTON_SIZE, - captureButtonUiState = CaptureButtonUiState.Enabled.Recording.PressedRecording, - switchWidth = (DEFAULT_CAPTURE_BUTTON_SIZE * LOCK_SWITCH_WIDTH_SCALE).dp, - switchPosition = 0f, - onToggleSwitchPosition = {}, - shouldBeLocked = { false } - ) - } - } +internal fun LockedRecordingPreview() { + PreviewCaptureButton( + captureButtonUiState = CaptureButtonUiState.Enabled.Recording.LockedRecording + ) } @Preview @Composable -private fun LockSwitchLockedAtThresholdPressedRecordingPreview() { +internal fun LockSwitchLockedAtThresholdPressedRecordingPreview() { // box is here to account for the offset lock switch - Box(modifier = Modifier.width(150.dp), contentAlignment = Alignment.CenterEnd) { - CaptureButtonRing(captureButtonSize = DEFAULT_CAPTURE_BUTTON_SIZE, color = Color.White) { + Box( + modifier = Modifier + .width(150.dp) + .background( + Brush.verticalGradient( + colors = listOf(Color.Gray, Color.DarkGray) + ) + ), + contentAlignment = Alignment.CenterEnd + ) { + CaptureButtonRing( + captureButtonSize = DEFAULT_CAPTURE_BUTTON_SIZE, + color = Color.Transparent + ) { LockSwitchCaptureButtonNucleus( captureButtonSize = DEFAULT_CAPTURE_BUTTON_SIZE, captureButtonUiState = CaptureButtonUiState.Enabled.Recording.PressedRecording, switchWidth = (DEFAULT_CAPTURE_BUTTON_SIZE * LOCK_SWITCH_WIDTH_SCALE).dp, - switchPosition = MINIMUM_LOCK_THRESHOLD, + switchPositionProvider = { MINIMUM_LOCK_THRESHOLD }, onToggleSwitchPosition = {}, - shouldBeLocked = { true } + shouldBeLocked = { true }, + isTapping = false ) } } @@ -936,17 +1126,30 @@ private fun LockSwitchLockedAtThresholdPressedRecordingPreview() { @Preview @Composable -private fun LockSwitchLockedPressedRecordingPreview() { +internal fun LockSwitchLockedPressedRecordingPreview() { // box is here to account for the offset lock switch - Box(modifier = Modifier.width(150.dp), contentAlignment = Alignment.CenterEnd) { - CaptureButtonRing(captureButtonSize = DEFAULT_CAPTURE_BUTTON_SIZE, color = Color.White) { + Box( + modifier = Modifier + .width(150.dp) + .background( + Brush.verticalGradient( + colors = listOf(Color.Gray, Color.DarkGray) + ) + ), + contentAlignment = Alignment.CenterEnd + ) { + CaptureButtonRing( + captureButtonSize = DEFAULT_CAPTURE_BUTTON_SIZE, + color = Color.Transparent + ) { LockSwitchCaptureButtonNucleus( captureButtonSize = DEFAULT_CAPTURE_BUTTON_SIZE, captureButtonUiState = CaptureButtonUiState.Enabled.Recording.PressedRecording, switchWidth = (DEFAULT_CAPTURE_BUTTON_SIZE * LOCK_SWITCH_WIDTH_SCALE).dp, - switchPosition = 1f, + switchPositionProvider = { 1f }, onToggleSwitchPosition = {}, - shouldBeLocked = { true } + shouldBeLocked = { true }, + isTapping = false ) } } diff --git a/ui/components/capture/src/main/res/values/strings.xml b/ui/components/capture/src/main/res/values/strings.xml index f73e4d3e4..2d025b921 100644 --- a/ui/components/capture/src/main/res/values/strings.xml +++ b/ui/components/capture/src/main/res/values/strings.xml @@ -127,4 +127,12 @@ View recently saved media + + + Capture Photo + Start Video Recording + Recording Video + Stop Video Recording + Lock Video Recording + Capture Button Unavailable \ No newline at end of file diff --git a/ui/components/capture/src/screenshotTest/kotlin/com/google/jetpackcamera/ui/components/capture/CaptureButtonScreenshotTest.kt b/ui/components/capture/src/screenshotTest/kotlin/com/google/jetpackcamera/ui/components/capture/CaptureButtonScreenshotTest.kt new file mode 100644 index 000000000..11dad4298 --- /dev/null +++ b/ui/components/capture/src/screenshotTest/kotlin/com/google/jetpackcamera/ui/components/capture/CaptureButtonScreenshotTest.kt @@ -0,0 +1,220 @@ +/* + * 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.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.android.tools.screenshot.PreviewTest +import com.google.jetpackcamera.model.CaptureMode +import com.google.jetpackcamera.ui.uistate.capture.CaptureButtonUiState + +// --- Standard Mode --- + +@PreviewTest +@Preview +@Composable +fun IdleStandardCaptureButtonScreenshotPreview() { + IdleStandardCaptureButtonPreview() +} + +@PreviewTest +@Preview +@Composable +fun IdleStandardCaptureButtonBlack60ScreenshotPreview() { + CompositionLocalProvider(LocalCameraControlBackgroundStyle provides CameraControlBackgroundStyle.BLACK_60) { + IdleStandardCaptureButtonPreview() + } +} + +@PreviewTest +@Preview +@Composable +fun DisabledStandardCaptureButtonScreenshotPreview() { + IdleStandardCaptureButtonDisabledPreview() +} + +@PreviewTest +@Preview +@Composable +fun DisabledStandardCaptureButtonBlack60ScreenshotPreview() { + CompositionLocalProvider(LocalCameraControlBackgroundStyle provides CameraControlBackgroundStyle.BLACK_60) { + IdleStandardCaptureButtonDisabledPreview() + } +} + +@PreviewTest +@Preview +@Composable +fun PressedStandardCaptureButtonScreenshotPreview() { + CompositionLocalProvider(LocalInitialPressedState provides true) { + PreviewCaptureButton( + captureButtonUiState = CaptureButtonUiState.Enabled.Idle(CaptureMode.STANDARD) + ) + } +} + +@PreviewTest +@Preview +@Composable +fun PressedStandardCaptureButtonBlack60ScreenshotPreview() { + CompositionLocalProvider( + LocalCameraControlBackgroundStyle provides CameraControlBackgroundStyle.BLACK_60, + LocalInitialPressedState provides true + ) { + PreviewCaptureButton( + captureButtonUiState = CaptureButtonUiState.Enabled.Idle(CaptureMode.STANDARD) + ) + } +} + +// --- Image Only Mode --- + +@PreviewTest +@Preview +@Composable +fun IdleImageCaptureButtonScreenshotPreview() { + IdleImageCaptureButtonPreview() +} + +@PreviewTest +@Preview +@Composable +fun IdleImageCaptureButtonBlack60ScreenshotPreview() { + CompositionLocalProvider(LocalCameraControlBackgroundStyle provides CameraControlBackgroundStyle.BLACK_60) { + IdleImageCaptureButtonPreview() + } +} + +@PreviewTest +@Preview +@Composable +fun DisabledImageCaptureButtonScreenshotPreview() { + IdleImageCaptureButtonDisabledPreview() +} + +@PreviewTest +@Preview +@Composable +fun DisabledImageCaptureButtonBlack60ScreenshotPreview() { + CompositionLocalProvider(LocalCameraControlBackgroundStyle provides CameraControlBackgroundStyle.BLACK_60) { + IdleImageCaptureButtonDisabledPreview() + } +} + +@PreviewTest +@Preview +@Composable +fun PressedImageCaptureButtonScreenshotPreview() { + CompositionLocalProvider(LocalInitialPressedState provides true) { + PreviewCaptureButton( + captureButtonUiState = CaptureButtonUiState.Enabled.Idle(CaptureMode.IMAGE_ONLY) + ) + } +} + +@PreviewTest +@Preview +@Composable +fun PressedImageCaptureButtonBlack60ScreenshotPreview() { + CompositionLocalProvider( + LocalCameraControlBackgroundStyle provides CameraControlBackgroundStyle.BLACK_60, + LocalInitialPressedState provides true + ) { + PreviewCaptureButton( + captureButtonUiState = CaptureButtonUiState.Enabled.Idle(CaptureMode.IMAGE_ONLY) + ) + } +} + +// --- Video Only Mode --- + +@PreviewTest +@Preview +@Composable +fun IdleVideoOnlyCaptureButtonScreenshotPreview() { + IdleVideoOnlyCaptureButtonPreview() +} + +@PreviewTest +@Preview +@Composable +fun IdleVideoOnlyCaptureButtonBlack60ScreenshotPreview() { + CompositionLocalProvider(LocalCameraControlBackgroundStyle provides CameraControlBackgroundStyle.BLACK_60) { + IdleVideoOnlyCaptureButtonPreview() + } +} + +@PreviewTest +@Preview +@Composable +fun DisabledVideoOnlyCaptureButtonScreenshotPreview() { + IdleVideoOnlyCaptureButtonDisabledPreview() +} + +@PreviewTest +@Preview +@Composable +fun DisabledVideoOnlyCaptureButtonBlack60ScreenshotPreview() { + CompositionLocalProvider(LocalCameraControlBackgroundStyle provides CameraControlBackgroundStyle.BLACK_60) { + IdleVideoOnlyCaptureButtonDisabledPreview() + } +} + +// --- Recording States --- + +@PreviewTest +@Preview +@Composable +fun PressedRecordingScreenshotPreview() { + PreviewCaptureButton( + modifier = Modifier.width(150.dp), + captureButtonUiState = CaptureButtonUiState.Enabled.Recording.PressedRecording + ) +} + +@PreviewTest +@Preview +@Composable +fun LockedRecordingScreenshotPreview() { + LockedRecordingPreview() +} + +@PreviewTest +@Preview +@Composable +fun PressedRecordingBlack60ScreenshotPreview() { + CompositionLocalProvider(LocalCameraControlBackgroundStyle provides CameraControlBackgroundStyle.BLACK_60) { + PreviewCaptureButton( + modifier = Modifier.width(150.dp), + captureButtonUiState = CaptureButtonUiState.Enabled.Recording.PressedRecording + ) + } +} + +@PreviewTest +@Preview +@Composable +fun LockedRecordingBlack60ScreenshotPreview() { + CompositionLocalProvider(LocalCameraControlBackgroundStyle provides CameraControlBackgroundStyle.BLACK_60) { + PreviewCaptureButton( + captureButtonUiState = CaptureButtonUiState.Enabled.Recording.LockedRecording + ) + } +} diff --git a/ui/components/capture/src/screenshotTestStableDebug/reference/com/google/jetpackcamera/ui/components/capture/CaptureButtonScreenshotTestKt/DisabledImageCaptureButtonBlack60ScreenshotPreview_0.png b/ui/components/capture/src/screenshotTestStableDebug/reference/com/google/jetpackcamera/ui/components/capture/CaptureButtonScreenshotTestKt/DisabledImageCaptureButtonBlack60ScreenshotPreview_0.png new file mode 100644 index 000000000..7748076b7 Binary files /dev/null and b/ui/components/capture/src/screenshotTestStableDebug/reference/com/google/jetpackcamera/ui/components/capture/CaptureButtonScreenshotTestKt/DisabledImageCaptureButtonBlack60ScreenshotPreview_0.png differ diff --git a/ui/components/capture/src/screenshotTestStableDebug/reference/com/google/jetpackcamera/ui/components/capture/CaptureButtonScreenshotTestKt/DisabledImageCaptureButtonScreenshotPreview_0.png b/ui/components/capture/src/screenshotTestStableDebug/reference/com/google/jetpackcamera/ui/components/capture/CaptureButtonScreenshotTestKt/DisabledImageCaptureButtonScreenshotPreview_0.png new file mode 100644 index 000000000..b4e34ecc8 Binary files /dev/null and b/ui/components/capture/src/screenshotTestStableDebug/reference/com/google/jetpackcamera/ui/components/capture/CaptureButtonScreenshotTestKt/DisabledImageCaptureButtonScreenshotPreview_0.png differ diff --git a/ui/components/capture/src/screenshotTestStableDebug/reference/com/google/jetpackcamera/ui/components/capture/CaptureButtonScreenshotTestKt/DisabledStandardCaptureButtonBlack60ScreenshotPreview_0.png b/ui/components/capture/src/screenshotTestStableDebug/reference/com/google/jetpackcamera/ui/components/capture/CaptureButtonScreenshotTestKt/DisabledStandardCaptureButtonBlack60ScreenshotPreview_0.png new file mode 100644 index 000000000..f764c47b9 Binary files /dev/null and b/ui/components/capture/src/screenshotTestStableDebug/reference/com/google/jetpackcamera/ui/components/capture/CaptureButtonScreenshotTestKt/DisabledStandardCaptureButtonBlack60ScreenshotPreview_0.png differ diff --git a/ui/components/capture/src/screenshotTestStableDebug/reference/com/google/jetpackcamera/ui/components/capture/CaptureButtonScreenshotTestKt/DisabledStandardCaptureButtonScreenshotPreview_0.png b/ui/components/capture/src/screenshotTestStableDebug/reference/com/google/jetpackcamera/ui/components/capture/CaptureButtonScreenshotTestKt/DisabledStandardCaptureButtonScreenshotPreview_0.png new file mode 100644 index 000000000..a7d77b001 Binary files /dev/null and b/ui/components/capture/src/screenshotTestStableDebug/reference/com/google/jetpackcamera/ui/components/capture/CaptureButtonScreenshotTestKt/DisabledStandardCaptureButtonScreenshotPreview_0.png differ diff --git a/ui/components/capture/src/screenshotTestStableDebug/reference/com/google/jetpackcamera/ui/components/capture/CaptureButtonScreenshotTestKt/DisabledVideoOnlyCaptureButtonBlack60ScreenshotPreview_0.png b/ui/components/capture/src/screenshotTestStableDebug/reference/com/google/jetpackcamera/ui/components/capture/CaptureButtonScreenshotTestKt/DisabledVideoOnlyCaptureButtonBlack60ScreenshotPreview_0.png new file mode 100644 index 000000000..bf4741c3b Binary files /dev/null and b/ui/components/capture/src/screenshotTestStableDebug/reference/com/google/jetpackcamera/ui/components/capture/CaptureButtonScreenshotTestKt/DisabledVideoOnlyCaptureButtonBlack60ScreenshotPreview_0.png differ diff --git a/ui/components/capture/src/screenshotTestStableDebug/reference/com/google/jetpackcamera/ui/components/capture/CaptureButtonScreenshotTestKt/DisabledVideoOnlyCaptureButtonScreenshotPreview_0.png b/ui/components/capture/src/screenshotTestStableDebug/reference/com/google/jetpackcamera/ui/components/capture/CaptureButtonScreenshotTestKt/DisabledVideoOnlyCaptureButtonScreenshotPreview_0.png new file mode 100644 index 000000000..716fe005e Binary files /dev/null and b/ui/components/capture/src/screenshotTestStableDebug/reference/com/google/jetpackcamera/ui/components/capture/CaptureButtonScreenshotTestKt/DisabledVideoOnlyCaptureButtonScreenshotPreview_0.png differ diff --git a/ui/components/capture/src/screenshotTestStableDebug/reference/com/google/jetpackcamera/ui/components/capture/CaptureButtonScreenshotTestKt/IdleImageCaptureButtonBlack60ScreenshotPreview_0.png b/ui/components/capture/src/screenshotTestStableDebug/reference/com/google/jetpackcamera/ui/components/capture/CaptureButtonScreenshotTestKt/IdleImageCaptureButtonBlack60ScreenshotPreview_0.png new file mode 100644 index 000000000..2cf1bf5a3 Binary files /dev/null and b/ui/components/capture/src/screenshotTestStableDebug/reference/com/google/jetpackcamera/ui/components/capture/CaptureButtonScreenshotTestKt/IdleImageCaptureButtonBlack60ScreenshotPreview_0.png differ diff --git a/ui/components/capture/src/screenshotTestStableDebug/reference/com/google/jetpackcamera/ui/components/capture/CaptureButtonScreenshotTestKt/IdleImageCaptureButtonScreenshotPreview_0.png b/ui/components/capture/src/screenshotTestStableDebug/reference/com/google/jetpackcamera/ui/components/capture/CaptureButtonScreenshotTestKt/IdleImageCaptureButtonScreenshotPreview_0.png new file mode 100644 index 000000000..352406ae7 Binary files /dev/null and b/ui/components/capture/src/screenshotTestStableDebug/reference/com/google/jetpackcamera/ui/components/capture/CaptureButtonScreenshotTestKt/IdleImageCaptureButtonScreenshotPreview_0.png differ diff --git a/ui/components/capture/src/screenshotTestStableDebug/reference/com/google/jetpackcamera/ui/components/capture/CaptureButtonScreenshotTestKt/IdleStandardCaptureButtonBlack60ScreenshotPreview_0.png b/ui/components/capture/src/screenshotTestStableDebug/reference/com/google/jetpackcamera/ui/components/capture/CaptureButtonScreenshotTestKt/IdleStandardCaptureButtonBlack60ScreenshotPreview_0.png new file mode 100644 index 000000000..ce155e88d Binary files /dev/null and b/ui/components/capture/src/screenshotTestStableDebug/reference/com/google/jetpackcamera/ui/components/capture/CaptureButtonScreenshotTestKt/IdleStandardCaptureButtonBlack60ScreenshotPreview_0.png differ diff --git a/ui/components/capture/src/screenshotTestStableDebug/reference/com/google/jetpackcamera/ui/components/capture/CaptureButtonScreenshotTestKt/IdleStandardCaptureButtonScreenshotPreview_0.png b/ui/components/capture/src/screenshotTestStableDebug/reference/com/google/jetpackcamera/ui/components/capture/CaptureButtonScreenshotTestKt/IdleStandardCaptureButtonScreenshotPreview_0.png new file mode 100644 index 000000000..93a1df57c Binary files /dev/null and b/ui/components/capture/src/screenshotTestStableDebug/reference/com/google/jetpackcamera/ui/components/capture/CaptureButtonScreenshotTestKt/IdleStandardCaptureButtonScreenshotPreview_0.png differ diff --git a/ui/components/capture/src/screenshotTestStableDebug/reference/com/google/jetpackcamera/ui/components/capture/CaptureButtonScreenshotTestKt/IdleVideoOnlyCaptureButtonBlack60ScreenshotPreview_0.png b/ui/components/capture/src/screenshotTestStableDebug/reference/com/google/jetpackcamera/ui/components/capture/CaptureButtonScreenshotTestKt/IdleVideoOnlyCaptureButtonBlack60ScreenshotPreview_0.png new file mode 100644 index 000000000..89be0a994 Binary files /dev/null and b/ui/components/capture/src/screenshotTestStableDebug/reference/com/google/jetpackcamera/ui/components/capture/CaptureButtonScreenshotTestKt/IdleVideoOnlyCaptureButtonBlack60ScreenshotPreview_0.png differ diff --git a/ui/components/capture/src/screenshotTestStableDebug/reference/com/google/jetpackcamera/ui/components/capture/CaptureButtonScreenshotTestKt/IdleVideoOnlyCaptureButtonScreenshotPreview_0.png b/ui/components/capture/src/screenshotTestStableDebug/reference/com/google/jetpackcamera/ui/components/capture/CaptureButtonScreenshotTestKt/IdleVideoOnlyCaptureButtonScreenshotPreview_0.png new file mode 100644 index 000000000..790101ad3 Binary files /dev/null and b/ui/components/capture/src/screenshotTestStableDebug/reference/com/google/jetpackcamera/ui/components/capture/CaptureButtonScreenshotTestKt/IdleVideoOnlyCaptureButtonScreenshotPreview_0.png differ diff --git a/ui/components/capture/src/screenshotTestStableDebug/reference/com/google/jetpackcamera/ui/components/capture/CaptureButtonScreenshotTestKt/LockedRecordingBlack60ScreenshotPreview_0.png b/ui/components/capture/src/screenshotTestStableDebug/reference/com/google/jetpackcamera/ui/components/capture/CaptureButtonScreenshotTestKt/LockedRecordingBlack60ScreenshotPreview_0.png new file mode 100644 index 000000000..26b4ebdda Binary files /dev/null and b/ui/components/capture/src/screenshotTestStableDebug/reference/com/google/jetpackcamera/ui/components/capture/CaptureButtonScreenshotTestKt/LockedRecordingBlack60ScreenshotPreview_0.png differ diff --git a/ui/components/capture/src/screenshotTestStableDebug/reference/com/google/jetpackcamera/ui/components/capture/CaptureButtonScreenshotTestKt/LockedRecordingScreenshotPreview_0.png b/ui/components/capture/src/screenshotTestStableDebug/reference/com/google/jetpackcamera/ui/components/capture/CaptureButtonScreenshotTestKt/LockedRecordingScreenshotPreview_0.png new file mode 100644 index 000000000..3410b62ad Binary files /dev/null and b/ui/components/capture/src/screenshotTestStableDebug/reference/com/google/jetpackcamera/ui/components/capture/CaptureButtonScreenshotTestKt/LockedRecordingScreenshotPreview_0.png differ diff --git a/ui/components/capture/src/screenshotTestStableDebug/reference/com/google/jetpackcamera/ui/components/capture/CaptureButtonScreenshotTestKt/PressedImageCaptureButtonBlack60ScreenshotPreview_0.png b/ui/components/capture/src/screenshotTestStableDebug/reference/com/google/jetpackcamera/ui/components/capture/CaptureButtonScreenshotTestKt/PressedImageCaptureButtonBlack60ScreenshotPreview_0.png new file mode 100644 index 000000000..704e42c1c Binary files /dev/null and b/ui/components/capture/src/screenshotTestStableDebug/reference/com/google/jetpackcamera/ui/components/capture/CaptureButtonScreenshotTestKt/PressedImageCaptureButtonBlack60ScreenshotPreview_0.png differ diff --git a/ui/components/capture/src/screenshotTestStableDebug/reference/com/google/jetpackcamera/ui/components/capture/CaptureButtonScreenshotTestKt/PressedImageCaptureButtonScreenshotPreview_0.png b/ui/components/capture/src/screenshotTestStableDebug/reference/com/google/jetpackcamera/ui/components/capture/CaptureButtonScreenshotTestKt/PressedImageCaptureButtonScreenshotPreview_0.png new file mode 100644 index 000000000..f8ec851aa Binary files /dev/null and b/ui/components/capture/src/screenshotTestStableDebug/reference/com/google/jetpackcamera/ui/components/capture/CaptureButtonScreenshotTestKt/PressedImageCaptureButtonScreenshotPreview_0.png differ diff --git a/ui/components/capture/src/screenshotTestStableDebug/reference/com/google/jetpackcamera/ui/components/capture/CaptureButtonScreenshotTestKt/PressedRecordingBlack60ScreenshotPreview_0.png b/ui/components/capture/src/screenshotTestStableDebug/reference/com/google/jetpackcamera/ui/components/capture/CaptureButtonScreenshotTestKt/PressedRecordingBlack60ScreenshotPreview_0.png new file mode 100644 index 000000000..a1dd91a3e Binary files /dev/null and b/ui/components/capture/src/screenshotTestStableDebug/reference/com/google/jetpackcamera/ui/components/capture/CaptureButtonScreenshotTestKt/PressedRecordingBlack60ScreenshotPreview_0.png differ diff --git a/ui/components/capture/src/screenshotTestStableDebug/reference/com/google/jetpackcamera/ui/components/capture/CaptureButtonScreenshotTestKt/PressedRecordingScreenshotPreview_0.png b/ui/components/capture/src/screenshotTestStableDebug/reference/com/google/jetpackcamera/ui/components/capture/CaptureButtonScreenshotTestKt/PressedRecordingScreenshotPreview_0.png new file mode 100644 index 000000000..13785ff2f Binary files /dev/null and b/ui/components/capture/src/screenshotTestStableDebug/reference/com/google/jetpackcamera/ui/components/capture/CaptureButtonScreenshotTestKt/PressedRecordingScreenshotPreview_0.png differ diff --git a/ui/components/capture/src/screenshotTestStableDebug/reference/com/google/jetpackcamera/ui/components/capture/CaptureButtonScreenshotTestKt/PressedStandardCaptureButtonBlack60ScreenshotPreview_0.png b/ui/components/capture/src/screenshotTestStableDebug/reference/com/google/jetpackcamera/ui/components/capture/CaptureButtonScreenshotTestKt/PressedStandardCaptureButtonBlack60ScreenshotPreview_0.png new file mode 100644 index 000000000..92946e3f5 Binary files /dev/null and b/ui/components/capture/src/screenshotTestStableDebug/reference/com/google/jetpackcamera/ui/components/capture/CaptureButtonScreenshotTestKt/PressedStandardCaptureButtonBlack60ScreenshotPreview_0.png differ diff --git a/ui/components/capture/src/screenshotTestStableDebug/reference/com/google/jetpackcamera/ui/components/capture/CaptureButtonScreenshotTestKt/PressedStandardCaptureButtonScreenshotPreview_0.png b/ui/components/capture/src/screenshotTestStableDebug/reference/com/google/jetpackcamera/ui/components/capture/CaptureButtonScreenshotTestKt/PressedStandardCaptureButtonScreenshotPreview_0.png new file mode 100644 index 000000000..bb61aff4a Binary files /dev/null and b/ui/components/capture/src/screenshotTestStableDebug/reference/com/google/jetpackcamera/ui/components/capture/CaptureButtonScreenshotTestKt/PressedStandardCaptureButtonScreenshotPreview_0.png differ