diff --git a/.gemini/styleguide.md b/.gemini/styleguide.md index dd76e4e19..64435ffb2 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). @@ -87,6 +88,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/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 90cfbe02a..b631e34ff 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 @@ -404,8 +404,9 @@ private fun ContentScreen( ) }, - viewfinder = { + viewfinder = { modifier -> PreviewDisplay( + modifier = modifier, previewDisplayUiState = captureUiState.previewDisplayUiState, onFlipCamera = onFlipCamera, onTapToFocus = cameraController?.let { it::tapToFocus } ?: { _, _ -> }, @@ -415,7 +416,7 @@ private fun ContentScreen( focusMeteringUiState = captureUiState.focusMeteringUiState ) }, - captureButton = { + captureButton = { modifier -> fun runCaptureAction(action: () -> Unit) { if ((captureUiState.quickSettingsUiState as? QuickSettingsUiState.Available) ?.quickSettingsIsOpen == true @@ -425,6 +426,7 @@ private fun ContentScreen( action() } CaptureButton( + modifier = modifier, captureButtonUiState = captureUiState.captureButtonUiState, isQuickSettingsOpen = ( captureUiState.quickSettingsUiState as? 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 1c4f9b336..e86fc2fd3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -16,6 +16,7 @@ accompanist = "0.37.3" kotlinPlugin = "2.2.0" androidGradlePlugin = "8.10.1" protobufPlugin = "0.9.5" +composeScreenshot = "0.0.1-alpha14" androidxActivityCompose = "1.10.1" androidxAppCompat = "1.7.1" @@ -64,6 +65,7 @@ androidx-benchmark-macro-junit4 = { module = "androidx.benchmark:benchmark-macro androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidxCoreKtx" } androidx-datastore = { module = "androidx.datastore:datastore", 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" } @@ -86,8 +88,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", version = "1.8.0-beta03" } +accessibility-test-framework = { module = "com.google.android.apps.common.testing.accessibility.framework:accessibility-test-framework", version = "4.1.1" } 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" } @@ -121,3 +126,4 @@ dagger-hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "hi google-protobuf = { id = "com.google.protobuf", version.ref = "protobufPlugin" } 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 70395892c..44ff4fce4 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() @@ -87,6 +90,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) @@ -98,6 +103,9 @@ dependencies { implementation(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")) @@ -116,3 +124,8 @@ dependencies { kapt { correctErrorTypes = true } +configurations.all { + resolutionStrategy { + 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 f9a1e5f14..e68c8fdeb 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,23 +15,27 @@ */ package com.google.jetpackcamera.ui.components.capture -import android.util.Log import android.view.KeyEvent import androidx.compose.animation.AnimatedVisibility 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 @@ -41,10 +45,14 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape 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 @@ -59,14 +67,18 @@ 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 @@ -79,7 +91,24 @@ 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 @@ -190,7 +219,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) } @@ -199,6 +229,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) @@ -217,7 +250,6 @@ internal fun CaptureButton( CaptureMode.STANDARD, CaptureMode.VIDEO_ONLY -> { isLongPressing.value = true - Log.d(TAG, "Starting recording") onStartRecording() } @@ -235,6 +267,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() @@ -245,12 +287,23 @@ 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 && currentUiState.value is CaptureButtonUiState.Enabled.Recording.PressedRecording ) { - Log.d(TAG, "Stopping recording") onStopRecording() } } @@ -263,7 +316,6 @@ internal fun CaptureButton( CaptureMode.VIDEO_ONLY -> { onLockVideoRecording(true) - Log.d(TAG, "Starting recording") onStartRecording() } } @@ -292,7 +344,8 @@ internal fun CaptureButton( onLockVideoRecording = onLockVideoRecording, onDragZoom = onIncrementZoom, captureButtonUiState = captureButtonUiState, - captureButtonSize = captureButtonSize + captureButtonSize = captureButtonSize, + interactionSource = interactionSource ) } @@ -336,11 +389,12 @@ 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 - var isCaptureButtonPressed by remember { - mutableStateOf(false) + val initialPressed = LocalInitialPressedState.current + var isCaptureButtonPressed by remember(initialPressed) { + mutableStateOf(initialPressed) } var switchPosition by remember { @@ -356,13 +410,30 @@ private fun CaptureButton( captureButtonUiState = captureButtonUiState ) + 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 = tween(durationMillis = if (isVisuallyDisabled) 1000 else 300), + animationSpec = tween( + durationMillis = + if (isVisuallyDisabled) ANIMATION_DURATION_DISABLED else ANIMATION_DURATION_COLOR + ), label = "Capture Button Color" ) @@ -404,10 +475,13 @@ private fun CaptureButton( // touch is dragged off the component onLongPress = {}, onPress = { - isCaptureButtonPressed = true + val press = PressInteraction.Press(it) + interactionSource.emit(press) + isCaptureButtonPressed = true // Manually set pressed state onPress() awaitRelease() - isCaptureButtonPressed = false + isCaptureButtonPressed = false // Manually unset pressed state + interactionSource.emit(PressInteraction.Release(press)) if (shouldBeLocked()) { onLockVideoRecording(true) onRelease(true) @@ -458,6 +532,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 { @@ -465,10 +545,22 @@ 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 + } } + .focusable() .then(gestureModifier), captureButtonSize = captureButtonSize, color = animatedColor @@ -480,27 +572,68 @@ private fun CaptureButton( switchWidth = switchWidth.dp, switchPosition = switchPosition, onToggleSwitchPosition = { toggleSwitchPosition() }, - shouldBeLocked = { shouldBeLocked() } + shouldBeLocked = { shouldBeLocked() }, + isVisuallyDisabled = isVisuallyDisabled, + isPressed = isCaptureButtonPressed || isPressedInteraction ) } else { CaptureButtonNucleus( captureButtonUiState = captureButtonUiState, - isPressed = isCaptureButtonPressed, - captureButtonSize = captureButtonSize + isPressed = isCaptureButtonPressed || isPressedInteraction, + captureButtonSize = captureButtonSize, + isVisuallyDisabled = isVisuallyDisabled ) } } } +/** + * Enum representing the background style of the shutter button. + */ +enum class ShutterBackgroundStyle { + WHITE_20, + BLACK_60 +} + +/** + * CompositionLocal to provide the [ShutterBackgroundStyle] to the capture button. + */ +val LocalShutterBackgroundStyle = compositionLocalOf { + ShutterBackgroundStyle.WHITE_20 +} + +/** + * 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 = LocalShutterBackgroundStyle.current + val targetBackgroundColor = when (backgroundStyle) { + ShutterBackgroundStyle.WHITE_20 -> Color.White.copy(alpha = ALPHA_WHITE_20) + ShutterBackgroundStyle.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 @@ -525,7 +658,9 @@ private fun LockSwitchCaptureButtonNucleus( switchWidth: Dp, switchPosition: Float, onToggleSwitchPosition: () -> Unit, - shouldBeLocked: () -> Boolean + shouldBeLocked: () -> Boolean, + isVisuallyDisabled: Boolean = false, + isPressed: Boolean ) { val pressedNucleusSize = (captureButtonSize * LOCK_SWITCH_PRESSED_NUCLEUS_SCALE).dp val switchHeight = (pressedNucleusSize * LOCK_SWITCH_HEIGHT_SCALE) @@ -539,7 +674,7 @@ private fun LockSwitchCaptureButtonNucleus( Box( contentAlignment = Alignment.CenterStart, modifier = Modifier - .width(switchWidth) + .width(switchWidth + 4.dp) .height(switchHeight) .offset(x = -(switchWidth - pressedNucleusSize) / 2) ) { @@ -572,7 +707,8 @@ private fun LockSwitchCaptureButtonNucleus( captureButtonSize = captureButtonSize, captureButtonUiState = captureButtonUiState, pressedVideoCaptureScale = LOCK_SWITCH_PRESSED_NUCLEUS_SCALE, - isPressed = false + isPressed = isPressed, + isVisuallyDisabled = isVisuallyDisabled ) // locked icon, matches cylinder offset @@ -582,27 +718,40 @@ private fun LockSwitchCaptureButtonNucleus( enter = 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 = null) { - onToggleSwitchPosition() + .size(32.dp) + .semantics { contentDescription = "Lock Video Recording" } + .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. * @@ -614,7 +763,7 @@ private fun LockSwitchCaptureButtonNucleus( * @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, isPressed: Boolean, @@ -622,9 +771,10 @@ private fun CaptureButtonNucleus( 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" @@ -639,10 +789,11 @@ private fun CaptureButtonNucleus( val currentUiState = rememberUpdatedState(captureButtonUiState) // 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.PressedRecording -> (captureButtonSize * pressedVideoCaptureScale).dp @@ -657,23 +808,104 @@ private fun CaptureButtonNucleus( CaptureMode.VIDEO_ONLY -> (captureButtonSize * idleVideoCaptureScale).dp } }, - animationSpec = tween(durationMillis = 500, easing = FastOutSlowInEasing) + animationSpec = tween( + durationMillis = ANIMATION_DURATION_SIZE, + easing = FastOutSlowInEasing + ) + ) + + val pressTransition = updateTransition( + targetState = isPressed && + 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 + isPressed && isPressableImageMode -> NucleusState.Pressed + else -> NucleusState.Idle + } + + val transition = + updateTransition(targetState = nucleusState, label = "Nucleus Color Transition") + val animatedColor by transition.animateColor( + label = "Nucleus Color", + transitionSpec = { + 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 (isPressed) recordingColor else imageCaptureModeColor + } - is CaptureButtonUiState.Enabled.Recording -> recordingColor - is CaptureButtonUiState.Unavailable -> Color.Transparent - }, - animationSpec = 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) { @@ -683,34 +915,42 @@ private fun CaptureButtonNucleus( contentAlignment = Alignment.Center, modifier = Modifier .size(centerShapeSize) - .clip(CircleShape) - .alpha( - if (isPressed && - currentUiState.value == - CaptureButtonUiState.Enabled.Idle(CaptureMode.IMAGE_ONLY) - ) { - .5f // transparency to indicate click ONLY on IMAGE_ONLY - } else { - 1f // solid alpha the rest of the time - } - ) + .clip(androidx.compose.foundation.shape.RoundedCornerShape(cornerRadius)) .background(animatedColor) ) {} } - // central "square" stop icon - AnimatedVisibility( - visible = currentUiState.value is - CaptureButtonUiState.Enabled.Recording.LockedRecording, - enter = scaleIn(initialScale = .5f) + fadeIn(), - exit = 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 ) } } @@ -719,177 +959,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), - isPressed = 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), - isPressed = 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), - isPressed = 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 - ), - isPressed = 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 - ), - isPressed = 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 - ), - isPressed = 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), - isPressed = 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), - isPressed = 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, - isPressed = 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, - isPressed = 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, onToggleSwitchPosition = {}, - shouldBeLocked = { true } + shouldBeLocked = { true }, + isPressed = false ) } } @@ -897,17 +1084,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, onToggleSwitchPosition = {}, - shouldBeLocked = { true } + shouldBeLocked = { true }, + isPressed = false ) } } diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureLayout.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureLayout.kt index 3084eec0c..db46f2dc6 100644 --- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureLayout.kt +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureLayout.kt @@ -34,10 +34,16 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -79,6 +85,16 @@ fun PreviewLayout( snackBar: @Composable (Modifier, snackbarHostState: SnackbarHostState) -> Unit ) { val snackbarHostState = remember { SnackbarHostState() } + + var viewfinderBottom by remember { mutableFloatStateOf(0f) } + var buttonTop by remember { mutableFloatStateOf(0f) } + var buttonBottom by remember { mutableFloatStateOf(0f) } + + val buttonHeight = buttonBottom - buttonTop + val isOverlapping = (viewfinderBottom - buttonTop) >= buttonHeight / 2 && + buttonHeight > 0 && + viewfinderBottom > 0 + Scaffold( modifier = Modifier.fillMaxSize(), snackbarHost = { SnackbarHost(hostState = snackbarHostState) } @@ -86,7 +102,11 @@ fun PreviewLayout( Box(modifier = modifier.background(Color.Black)) { Column { indicatorRow(Modifier.statusBarsPadding()) - viewfinder(Modifier) + viewfinder( + Modifier.onGloballyPositioned { coordinates -> + viewfinderBottom = coordinates.boundsInWindow().bottom + } + ) } Box( @@ -96,17 +116,29 @@ fun PreviewLayout( .safeDrawingPadding() ) { - debugVisibilityWrapper { - VerticalMaterialControls( - captureButton = captureButton, - imageWell = imageWell, - flipCameraButton = flipCameraButton, - quickSettingsToggleButton = quickSettingsButton, - captureModeToggleSwitch = captureModeToggle, - bottomSheetQuickSettings = quickSettingsOverlay, - zoomControls = zoomLevelDisplay, - elapsedTimeDisplay = elapsedTimeDisplay - ) + val backgroundStyle = if (isOverlapping) { + ShutterBackgroundStyle.BLACK_60 + } else { + ShutterBackgroundStyle.WHITE_20 + } + + CompositionLocalProvider(LocalShutterBackgroundStyle provides backgroundStyle) { + debugVisibilityWrapper { + VerticalMaterialControls( + buttonModifier = Modifier.onGloballyPositioned { coordinates -> + buttonTop = coordinates.boundsInWindow().top + buttonBottom = coordinates.boundsInWindow().bottom + }, + captureButton = captureButton, + imageWell = imageWell, + flipCameraButton = flipCameraButton, + quickSettingsToggleButton = quickSettingsButton, + captureModeToggleSwitch = captureModeToggle, + bottomSheetQuickSettings = quickSettingsOverlay, + zoomControls = zoomLevelDisplay, + elapsedTimeDisplay = elapsedTimeDisplay + ) + } } // controls overlay snackBar(Modifier, snackbarHostState) @@ -120,6 +152,7 @@ fun PreviewLayout( @Composable private fun VerticalMaterialControls( modifier: Modifier = Modifier, + buttonModifier: Modifier = Modifier, captureButton: @Composable (Modifier) -> Unit, zoomControls: @Composable (Modifier) -> Unit, imageWell: @Composable (Modifier) -> Unit, @@ -156,7 +189,7 @@ private fun VerticalMaterialControls( imageWell(Modifier) } } - captureButton(Modifier) + captureButton(buttonModifier) // right capturebutton item Box( 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 59578e45a..b046089eb 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 @@ -478,7 +478,7 @@ fun PreviewDisplay( surfaceRequest?.let { BoxWithConstraints( - modifier + Modifier .testTag(PREVIEW_DISPLAY) .fillMaxSize() .background(Color.Black), @@ -513,7 +513,7 @@ fun PreviewDisplay( } Box( - modifier = Modifier + modifier = modifier .width(width) .height(height) .transformable(state = transformableState) diff --git a/ui/components/capture/src/main/res/values/strings.xml b/ui/components/capture/src/main/res/values/strings.xml index b551cfec0..d79c17597 100644 --- a/ui/components/capture/src/main/res/values/strings.xml +++ b/ui/components/capture/src/main/res/values/strings.xml @@ -141,4 +141,11 @@ View recently saved media + + + Capture Photo + Start Video Recording + Recording Video + Stop 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..9bb9386b6 --- /dev/null +++ b/ui/components/capture/src/screenshotTest/kotlin/com/google/jetpackcamera/ui/components/capture/CaptureButtonScreenshotTest.kt @@ -0,0 +1,218 @@ +/* + * 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(LocalShutterBackgroundStyle provides ShutterBackgroundStyle.BLACK_60) { + IdleStandardCaptureButtonPreview() + } +} + +@PreviewTest +@Preview +@Composable +fun DisabledStandardCaptureButtonScreenshotPreview() { + IdleStandardCaptureButtonDisabledPreview() +} + +@PreviewTest +@Preview +@Composable +fun DisabledStandardCaptureButtonBlack60ScreenshotPreview() { + CompositionLocalProvider(LocalShutterBackgroundStyle provides ShutterBackgroundStyle.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( + LocalShutterBackgroundStyle provides ShutterBackgroundStyle.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(LocalShutterBackgroundStyle provides ShutterBackgroundStyle.BLACK_60) { + IdleImageCaptureButtonPreview() + } +} + +@PreviewTest +@Preview +@Composable +fun DisabledImageCaptureButtonScreenshotPreview() { + IdleImageCaptureButtonDisabledPreview() +} + +@PreviewTest +@Preview +@Composable +fun DisabledImageCaptureButtonBlack60ScreenshotPreview() { + CompositionLocalProvider(LocalShutterBackgroundStyle provides ShutterBackgroundStyle.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(LocalShutterBackgroundStyle provides ShutterBackgroundStyle.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(LocalShutterBackgroundStyle provides ShutterBackgroundStyle.BLACK_60) { + IdleVideoOnlyCaptureButtonPreview() + } +} + +@PreviewTest +@Preview +@Composable +fun DisabledVideoOnlyCaptureButtonScreenshotPreview() { + IdleVideoOnlyCaptureButtonDisabledPreview() +} + +@PreviewTest +@Preview +@Composable +fun DisabledVideoOnlyCaptureButtonBlack60ScreenshotPreview() { + CompositionLocalProvider(LocalShutterBackgroundStyle provides ShutterBackgroundStyle.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(LocalShutterBackgroundStyle provides ShutterBackgroundStyle.BLACK_60) { + PreviewCaptureButton( + modifier = Modifier.width(150.dp), + captureButtonUiState = CaptureButtonUiState.Enabled.Recording.PressedRecording + ) + } +} + +@PreviewTest +@Preview +@Composable +fun LockedRecordingBlack60ScreenshotPreview() { + CompositionLocalProvider(LocalShutterBackgroundStyle provides ShutterBackgroundStyle.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