From e448e8002009286148021ceca2b2c966b7b5ffba Mon Sep 17 00:00:00 2001 From: Trevor McGuire Date: Mon, 12 Jan 2026 17:09:08 -0800 Subject: [PATCH 01/10] Update CaptureButton Compose Previews to use real logic This also adds an additional interactionSource to the capture button so we can emulate touch events in our compose previews. --- .../capture/CaptureButtonComponents.kt | 198 +++++++++--------- 1 file changed, 95 insertions(+), 103 deletions(-) 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 0cc314aab..b2faca086 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 @@ -32,6 +32,9 @@ import androidx.compose.foundation.border import androidx.compose.foundation.clickable 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 @@ -44,6 +47,7 @@ import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.LockOpen import androidx.compose.material3.Icon import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -167,7 +171,8 @@ 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) } @@ -269,7 +274,8 @@ fun CaptureButton( onLockVideoRecording = onLockVideoRecording, onDragZoom = onIncrementZoom, captureButtonUiState = captureButtonUiState, - captureButtonSize = captureButtonSize + captureButtonSize = captureButtonSize, + interactionSource = interactionSource ) } @@ -313,13 +319,9 @@ 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) - } - var switchPosition by remember { mutableFloatStateOf(LOCK_SWITCH_POSITION_OFF) } @@ -333,6 +335,8 @@ private fun CaptureButton( captureButtonUiState = captureButtonUiState ) + val isCaptureButtonPressed by interactionSource.collectIsPressedAsState() + val animatedColor by animateColorAsState( targetValue = if (isVisuallyDisabled) { LocalContentColor.current.copy(alpha = 0.38f) @@ -381,10 +385,11 @@ private fun CaptureButton( // touch is dragged off the component onLongPress = {}, onPress = { - isCaptureButtonPressed = true + val press = PressInteraction.Press(it) + interactionSource.emit(press) onPress() awaitRelease() - isCaptureButtonPressed = false + interactionSource.emit(PressInteraction.Release(press)) if (shouldBeLocked()) { onLockVideoRecording(true) onRelease(true) @@ -457,13 +462,15 @@ private fun CaptureButton( switchWidth = switchWidth.dp, switchPosition = switchPosition, onToggleSwitchPosition = { toggleSwitchPosition() }, - shouldBeLocked = { shouldBeLocked() } + shouldBeLocked = { shouldBeLocked() }, + isVisuallyDisabled = isVisuallyDisabled ) } else { CaptureButtonNucleus( captureButtonUiState = captureButtonUiState, isPressed = isCaptureButtonPressed, - captureButtonSize = captureButtonSize + captureButtonSize = captureButtonSize, + isVisuallyDisabled = isVisuallyDisabled ) } } @@ -502,7 +509,8 @@ private fun LockSwitchCaptureButtonNucleus( switchWidth: Dp, switchPosition: Float, onToggleSwitchPosition: () -> Unit, - shouldBeLocked: () -> Boolean + shouldBeLocked: () -> Boolean, + isVisuallyDisabled: Boolean = false ) { val pressedNucleusSize = (captureButtonSize * LOCK_SWITCH_PRESSED_NUCLEUS_SCALE).dp val switchHeight = (pressedNucleusSize * LOCK_SWITCH_HEIGHT_SCALE) @@ -549,7 +557,8 @@ private fun LockSwitchCaptureButtonNucleus( captureButtonSize = captureButtonSize, captureButtonUiState = captureButtonUiState, pressedVideoCaptureScale = LOCK_SWITCH_PRESSED_NUCLEUS_SCALE, - isPressed = false + isPressed = false, + isVisuallyDisabled = isVisuallyDisabled ) // locked icon, matches cylinder offset @@ -601,7 +610,8 @@ private fun CaptureButtonNucleus( imageCaptureModeColor: Color = Color.White, idleImageCaptureScale: Float = .7f, idleVideoCaptureScale: Float = .35f, - pressedVideoCaptureScale: Float = .7f + pressedVideoCaptureScale: Float = .7f, + 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,15 +649,19 @@ private fun CaptureButtonNucleus( // 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.Available.Idle -> when (uiState.captureMode) { - CaptureMode.STANDARD -> imageCaptureModeColor - CaptureMode.IMAGE_ONLY -> imageCaptureModeColor - CaptureMode.VIDEO_ONLY -> recordingColor - } + targetValue = if (isVisuallyDisabled) { + LocalContentColor.current.copy(alpha = 0.38f) + } else { + when (val uiState = currentUiState.value) { + is CaptureButtonUiState.Available.Idle -> when (uiState.captureMode) { + CaptureMode.STANDARD -> imageCaptureModeColor + CaptureMode.IMAGE_ONLY -> imageCaptureModeColor + CaptureMode.VIDEO_ONLY -> recordingColor + } - is CaptureButtonUiState.Available.Recording -> recordingColor - is CaptureButtonUiState.Unavailable -> Color.Transparent + is CaptureButtonUiState.Available.Recording -> recordingColor + is CaptureButtonUiState.Unavailable -> Color.Transparent + } }, animationSpec = tween(durationMillis = 500) ) @@ -707,133 +721,111 @@ private fun CaptureButtonUnavailablePreview() { ) } -@Preview @Composable -private fun IdleStandardCaptureButtonPreview() { - CaptureButtonRing(captureButtonSize = DEFAULT_CAPTURE_BUTTON_SIZE, color = Color.White) { - CaptureButtonNucleus( - captureButtonUiState = CaptureButtonUiState.Available.Idle(CaptureMode.STANDARD), - isPressed = false, - captureButtonSize = DEFAULT_CAPTURE_BUTTON_SIZE +private fun PreviewCaptureButton( + captureButtonUiState: CaptureButtonUiState, + modifier: Modifier = Modifier, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() } +) { + Surface(color = Color.Black, contentColor = Color.White) { + CaptureButton( + modifier = modifier, + onImageCapture = {}, + onStartRecording = {}, + onStopRecording = {}, + onLockVideoRecording = {}, + onIncrementZoom = {}, + captureButtonUiState = captureButtonUiState, + interactionSource = interactionSource ) } } +@Preview +@Composable +private fun IdleStandardCaptureButtonPreview() { + PreviewCaptureButton( + captureButtonUiState = CaptureButtonUiState.Available.Idle(CaptureMode.STANDARD) + ) +} + @Preview @Composable private fun IdleImageCaptureButtonPreview() { - CaptureButtonRing(captureButtonSize = DEFAULT_CAPTURE_BUTTON_SIZE, color = Color.White) { - CaptureButtonNucleus( - captureButtonUiState = CaptureButtonUiState.Available.Idle(CaptureMode.IMAGE_ONLY), - isPressed = false, - captureButtonSize = DEFAULT_CAPTURE_BUTTON_SIZE - ) - } + PreviewCaptureButton( + captureButtonUiState = CaptureButtonUiState.Available.Idle(CaptureMode.IMAGE_ONLY) + ) } @Preview @Composable private fun IdleVideoOnlyCaptureButtonPreview() { - CaptureButtonRing(captureButtonSize = DEFAULT_CAPTURE_BUTTON_SIZE, color = Color.White) { - CaptureButtonNucleus( - captureButtonUiState = CaptureButtonUiState.Available.Idle(CaptureMode.VIDEO_ONLY), - isPressed = false, - captureButtonSize = DEFAULT_CAPTURE_BUTTON_SIZE - ) - } + PreviewCaptureButton( + captureButtonUiState = CaptureButtonUiState.Available.Idle(CaptureMode.VIDEO_ONLY) + ) } @Preview @Composable private fun IdleStandardCaptureButtonDisabledPreview() { - CaptureButtonRing(captureButtonSize = DEFAULT_CAPTURE_BUTTON_SIZE, color = Color.Gray) { - CaptureButtonNucleus( - captureButtonUiState = CaptureButtonUiState.Available.Idle( - CaptureMode.STANDARD, - isEnabled = false - ), - isPressed = false, - captureButtonSize = DEFAULT_CAPTURE_BUTTON_SIZE + PreviewCaptureButton( + captureButtonUiState = CaptureButtonUiState.Available.Idle( + CaptureMode.STANDARD, + isEnabled = false ) - } + ) } @Preview @Composable private fun IdleImageCaptureButtonDisabledPreview() { - CaptureButtonRing(captureButtonSize = DEFAULT_CAPTURE_BUTTON_SIZE, color = Color.Gray) { - CaptureButtonNucleus( - captureButtonUiState = CaptureButtonUiState.Available.Idle( - CaptureMode.IMAGE_ONLY, - isEnabled = false - ), - isPressed = false, - captureButtonSize = DEFAULT_CAPTURE_BUTTON_SIZE + PreviewCaptureButton( + captureButtonUiState = CaptureButtonUiState.Available.Idle( + CaptureMode.IMAGE_ONLY, + isEnabled = false ) - } + ) } @Preview @Composable private fun IdleVideoOnlyCaptureButtonDisabledPreview() { - CaptureButtonRing(captureButtonSize = DEFAULT_CAPTURE_BUTTON_SIZE, color = Color.Gray) { - CaptureButtonNucleus( - captureButtonUiState = CaptureButtonUiState.Available.Idle( - CaptureMode.VIDEO_ONLY, - isEnabled = false - ), - isPressed = false, - captureButtonSize = DEFAULT_CAPTURE_BUTTON_SIZE + PreviewCaptureButton( + captureButtonUiState = CaptureButtonUiState.Available.Idle( + CaptureMode.VIDEO_ONLY, + isEnabled = false ) - } + ) } @Preview @Composable private fun PressedImageCaptureButtonPreview() { - CaptureButtonRing(captureButtonSize = DEFAULT_CAPTURE_BUTTON_SIZE, color = Color.White) { - CaptureButtonNucleus( - captureButtonUiState = CaptureButtonUiState.Available.Idle(CaptureMode.IMAGE_ONLY), - isPressed = true, - captureButtonSize = DEFAULT_CAPTURE_BUTTON_SIZE - ) - } -} - -@Preview -@Composable -private fun IdleRecordingCaptureButtonPreview() { - CaptureButtonRing(captureButtonSize = DEFAULT_CAPTURE_BUTTON_SIZE, color = Color.White) { - CaptureButtonNucleus( - captureButtonUiState = CaptureButtonUiState.Available.Idle(CaptureMode.VIDEO_ONLY), - isPressed = false, - captureButtonSize = DEFAULT_CAPTURE_BUTTON_SIZE - ) + val interactionSource = remember { MutableInteractionSource() } + LaunchedEffect(Unit) { + interactionSource.emit(PressInteraction.Press(Offset.Zero)) } + PreviewCaptureButton( + captureButtonUiState = CaptureButtonUiState.Available.Idle(CaptureMode.IMAGE_ONLY), + interactionSource = interactionSource + ) } @Preview @Composable private fun SimpleNucleusPressedRecordingPreview() { - CaptureButtonRing(captureButtonSize = DEFAULT_CAPTURE_BUTTON_SIZE, color = Color.White) { - CaptureButtonNucleus( - captureButtonUiState = CaptureButtonUiState.Available.Recording.PressedRecording, - isPressed = true, - captureButtonSize = DEFAULT_CAPTURE_BUTTON_SIZE - ) - } + // This state is visual only based on UI State, doesn't require press interaction to look pressed + PreviewCaptureButton( + captureButtonUiState = CaptureButtonUiState.Available.Recording.PressedRecording + ) } @Preview @Composable private fun LockedRecordingPreview() { - CaptureButtonRing(captureButtonSize = DEFAULT_CAPTURE_BUTTON_SIZE, color = Color.White) { - CaptureButtonNucleus( - captureButtonUiState = CaptureButtonUiState.Available.Recording.LockedRecording, - isPressed = false, - captureButtonSize = DEFAULT_CAPTURE_BUTTON_SIZE - ) - } + PreviewCaptureButton( + captureButtonUiState = CaptureButtonUiState.Available.Recording.LockedRecording + ) } @Preview From cc0ca6b8842622c0a3a05d4d52180f384d5d17b1 Mon Sep 17 00:00:00 2001 From: Trevor McGuire Date: Mon, 12 Jan 2026 17:28:19 -0800 Subject: [PATCH 02/10] Simplify Preview logic for CaptureButtonComponent --- .../capture/CaptureButtonComponents.kt | 51 +++++++------------ 1 file changed, 19 insertions(+), 32 deletions(-) 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 b2faca086..92b2aa423 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 @@ -725,19 +725,22 @@ private fun CaptureButtonUnavailablePreview() { private fun PreviewCaptureButton( captureButtonUiState: CaptureButtonUiState, modifier: Modifier = Modifier, + contentAlignment: Alignment = Alignment.Center, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() } ) { - Surface(color = Color.Black, contentColor = Color.White) { - CaptureButton( - modifier = modifier, - onImageCapture = {}, - onStartRecording = {}, - onStopRecording = {}, - onLockVideoRecording = {}, - onIncrementZoom = {}, - captureButtonUiState = captureButtonUiState, - interactionSource = interactionSource - ) + Surface(color = Color.Black, contentColor = Color.White, modifier = modifier) { + Box(contentAlignment = contentAlignment) { + CaptureButton( + modifier = Modifier, + onImageCapture = {}, + onStartRecording = {}, + onStopRecording = {}, + onLockVideoRecording = {}, + onIncrementZoom = {}, + captureButtonUiState = captureButtonUiState, + interactionSource = interactionSource + ) + } } } @@ -813,10 +816,12 @@ private fun PressedImageCaptureButtonPreview() { @Preview @Composable -private fun SimpleNucleusPressedRecordingPreview() { - // This state is visual only based on UI State, doesn't require press interaction to look pressed +private fun LockSwitchUnlockedPressedRecordingPreview() { + // box is here to account for the offset lock switch PreviewCaptureButton( - captureButtonUiState = CaptureButtonUiState.Available.Recording.PressedRecording + captureButtonUiState = CaptureButtonUiState.Available.Recording.PressedRecording, + modifier = Modifier.width(150.dp), + contentAlignment = Alignment.CenterEnd ) } @@ -828,24 +833,6 @@ private fun LockedRecordingPreview() { ) } -@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.Available.Recording.PressedRecording, - switchWidth = (DEFAULT_CAPTURE_BUTTON_SIZE * LOCK_SWITCH_WIDTH_SCALE).dp, - switchPosition = 0f, - onToggleSwitchPosition = {}, - shouldBeLocked = { false } - ) - } - } -} - @Preview @Composable private fun LockSwitchLockedAtThresholdPressedRecordingPreview() { From 89b9ddb1806ffdb0d1e8ed75defb7ec3a2a989bc Mon Sep 17 00:00:00 2001 From: Trevor McGuire Date: Mon, 12 Jan 2026 19:02:39 -0800 Subject: [PATCH 03/10] Ensure pressed state for IMAGE_ONLY is right color Also ensures the disabled state for the capture button has the correct animations for the nucleus. --- .../capture/CaptureButtonComponents.kt | 147 +++++++++++------- 1 file changed, 92 insertions(+), 55 deletions(-) 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 92b2aa423..64cbc8f37 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,14 +15,16 @@ */ 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.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 @@ -47,7 +49,9 @@ import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.LockOpen import androidx.compose.material3.Icon import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface +import androidx.compose.material3.darkColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -199,7 +203,6 @@ fun CaptureButton( CaptureMode.STANDARD, CaptureMode.VIDEO_ONLY -> { isLongPressing.value = true - Log.d(TAG, "Starting recording") onStartRecording() } @@ -232,7 +235,6 @@ fun CaptureButton( currentUiState.value is CaptureButtonUiState.Available.Recording.PressedRecording ) { - Log.d(TAG, "Stopping recording") onStopRecording() } } @@ -245,7 +247,6 @@ fun CaptureButton( CaptureMode.VIDEO_ONLY -> { onLockVideoRecording(true) - Log.d(TAG, "Starting recording") onStartRecording() } } @@ -322,6 +323,10 @@ private fun CaptureButton( captureButtonSize: Float = DEFAULT_CAPTURE_BUTTON_SIZE, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() } ) { + var isCaptureButtonPressed by remember { + mutableStateOf(false) + } + var switchPosition by remember { mutableFloatStateOf(LOCK_SWITCH_POSITION_OFF) } @@ -335,7 +340,7 @@ private fun CaptureButton( captureButtonUiState = captureButtonUiState ) - val isCaptureButtonPressed by interactionSource.collectIsPressedAsState() + val isPressedInteraction by interactionSource.collectIsPressedAsState() val animatedColor by animateColorAsState( targetValue = if (isVisuallyDisabled) { @@ -387,8 +392,10 @@ private fun CaptureButton( onPress = { val press = PressInteraction.Press(it) interactionSource.emit(press) + isCaptureButtonPressed = true // Manually set pressed state onPress() awaitRelease() + isCaptureButtonPressed = false // Manually unset pressed state interactionSource.emit(PressInteraction.Release(press)) if (shouldBeLocked()) { onLockVideoRecording(true) @@ -463,12 +470,13 @@ private fun CaptureButton( switchPosition = switchPosition, onToggleSwitchPosition = { toggleSwitchPosition() }, shouldBeLocked = { shouldBeLocked() }, - isVisuallyDisabled = isVisuallyDisabled + isVisuallyDisabled = isVisuallyDisabled, + isPressed = isCaptureButtonPressed || isPressedInteraction ) } else { CaptureButtonNucleus( captureButtonUiState = captureButtonUiState, - isPressed = isCaptureButtonPressed, + isPressed = isCaptureButtonPressed || isPressedInteraction, captureButtonSize = captureButtonSize, isVisuallyDisabled = isVisuallyDisabled ) @@ -510,7 +518,8 @@ private fun LockSwitchCaptureButtonNucleus( switchPosition: Float, onToggleSwitchPosition: () -> Unit, shouldBeLocked: () -> Boolean, - isVisuallyDisabled: Boolean = false + isVisuallyDisabled: Boolean = false, + isPressed: Boolean ) { val pressedNucleusSize = (captureButtonSize * LOCK_SWITCH_PRESSED_NUCLEUS_SCALE).dp val switchHeight = (pressedNucleusSize * LOCK_SWITCH_HEIGHT_SCALE) @@ -557,7 +566,7 @@ private fun LockSwitchCaptureButtonNucleus( captureButtonSize = captureButtonSize, captureButtonUiState = captureButtonUiState, pressedVideoCaptureScale = LOCK_SWITCH_PRESSED_NUCLEUS_SCALE, - isPressed = false, + isPressed = isPressed, isVisuallyDisabled = isVisuallyDisabled ) @@ -589,6 +598,10 @@ private fun LockSwitchCaptureButtonNucleus( } } +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. * @@ -648,23 +661,44 @@ private fun CaptureButtonNucleus( ) // used to fade between red/white in the center of the capture button - val animatedColor by animateColorAsState( - targetValue = if (isVisuallyDisabled) { - LocalContentColor.current.copy(alpha = 0.38f) - } else { - when (val uiState = currentUiState.value) { - is CaptureButtonUiState.Available.Idle -> when (uiState.captureMode) { - CaptureMode.STANDARD -> imageCaptureModeColor - CaptureMode.IMAGE_ONLY -> imageCaptureModeColor - CaptureMode.VIDEO_ONLY -> recordingColor - } + val isPressableImageMode = currentUiState.value.let { + it is CaptureButtonUiState.Available.Idle && + (it.captureMode == CaptureMode.IMAGE_ONLY || it.captureMode == CaptureMode.STANDARD) + } + val nucleusState = when { + isVisuallyDisabled -> NucleusState.Disabled + isPressed && isPressableImageMode -> NucleusState.Pressed + else -> NucleusState.Idle + } - is CaptureButtonUiState.Available.Recording -> recordingColor - is CaptureButtonUiState.Unavailable -> Color.Transparent + 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 = 300) + NucleusState.Idle isTransitioningTo NucleusState.Disabled -> tween(durationMillis = 1000) + else -> snap() } - }, - animationSpec = tween(durationMillis = 500) - ) + } + ) { state -> + when (state) { + NucleusState.Disabled -> LocalContentColor.current.copy(alpha = 0.38f) + NucleusState.Pressed -> MaterialTheme.colorScheme.primaryFixedDim + NucleusState.Idle -> { + when (val uiState = currentUiState.value) { + is CaptureButtonUiState.Available.Idle -> when (uiState.captureMode) { + CaptureMode.STANDARD -> imageCaptureModeColor + CaptureMode.IMAGE_ONLY -> imageCaptureModeColor + CaptureMode.VIDEO_ONLY -> recordingColor + } + + is CaptureButtonUiState.Available.Recording -> recordingColor + is CaptureButtonUiState.Unavailable -> Color.Transparent + } + } + } + } // this box contains and centers everything Box(modifier = modifier.offset(x = offsetX), contentAlignment = Alignment.Center) { @@ -675,16 +709,6 @@ private fun CaptureButtonNucleus( modifier = Modifier .size(centerShapeSize) .clip(CircleShape) - .alpha( - if (isPressed && - currentUiState.value == - CaptureButtonUiState.Available.Idle(CaptureMode.IMAGE_ONLY) - ) { - .5f // transparency to indicate click ONLY on IMAGE_ONLY - } else { - 1f // solid alpha the rest of the time - } - ) .background(animatedColor) ) {} } @@ -728,18 +752,20 @@ private fun PreviewCaptureButton( contentAlignment: Alignment = Alignment.Center, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() } ) { - Surface(color = Color.Black, contentColor = Color.White, modifier = modifier) { - Box(contentAlignment = contentAlignment) { - CaptureButton( - modifier = Modifier, - onImageCapture = {}, - onStartRecording = {}, - onStopRecording = {}, - onLockVideoRecording = {}, - onIncrementZoom = {}, - captureButtonUiState = captureButtonUiState, - interactionSource = interactionSource - ) + MaterialTheme(colorScheme = darkColorScheme()) { + Surface(color = Color.Black, contentColor = Color.White, modifier = modifier) { + Box(contentAlignment = contentAlignment) { + CaptureButton( + modifier = Modifier, + onImageCapture = {}, + onStartRecording = {}, + onStopRecording = {}, + onLockVideoRecording = {}, + onIncrementZoom = {}, + captureButtonUiState = captureButtonUiState, + interactionSource = interactionSource + ) + } } } } @@ -804,14 +830,23 @@ private fun IdleVideoOnlyCaptureButtonDisabledPreview() { @Preview @Composable private fun PressedImageCaptureButtonPreview() { - val interactionSource = remember { MutableInteractionSource() } - LaunchedEffect(Unit) { - interactionSource.emit(PressInteraction.Press(Offset.Zero)) + // Manually constructed preview to verify visual state without relying on interaction source + MaterialTheme(colorScheme = darkColorScheme()) { + Surface(color = Color.Black, contentColor = Color.White) { + Box(contentAlignment = Alignment.Center) { + CaptureButtonRing( + captureButtonSize = DEFAULT_CAPTURE_BUTTON_SIZE, + color = Color.White + ) { + CaptureButtonNucleus( + captureButtonUiState = CaptureButtonUiState.Available.Idle(CaptureMode.IMAGE_ONLY), + isPressed = true, + captureButtonSize = DEFAULT_CAPTURE_BUTTON_SIZE + ) + } + } + } } - PreviewCaptureButton( - captureButtonUiState = CaptureButtonUiState.Available.Idle(CaptureMode.IMAGE_ONLY), - interactionSource = interactionSource - ) } @Preview @@ -845,7 +880,8 @@ private fun LockSwitchLockedAtThresholdPressedRecordingPreview() { switchWidth = (DEFAULT_CAPTURE_BUTTON_SIZE * LOCK_SWITCH_WIDTH_SCALE).dp, switchPosition = MINIMUM_LOCK_THRESHOLD, onToggleSwitchPosition = {}, - shouldBeLocked = { true } + shouldBeLocked = { true }, + isPressed = false ) } } @@ -863,7 +899,8 @@ private fun LockSwitchLockedPressedRecordingPreview() { switchWidth = (DEFAULT_CAPTURE_BUTTON_SIZE * LOCK_SWITCH_WIDTH_SCALE).dp, switchPosition = 1f, onToggleSwitchPosition = {}, - shouldBeLocked = { true } + shouldBeLocked = { true }, + isPressed = false ) } } From 4737c83107b987567ed70dbb1893f085dce8913f Mon Sep 17 00:00:00 2001 From: Trevor McGuire Date: Mon, 12 Jan 2026 19:13:07 -0800 Subject: [PATCH 04/10] Make capture button nucles correct color and size during capture Also animates to/from the pressed state --- .../capture/CaptureButtonComponents.kt | 35 +++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) 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 64cbc8f37..4ee8e7a67 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 @@ -21,6 +21,7 @@ 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 @@ -348,7 +349,11 @@ private fun CaptureButton( } else { LocalContentColor.current }, - animationSpec = tween(durationMillis = if (isVisuallyDisabled) 1000 else 300), + animationSpec = if (isVisuallyDisabled) { + tween(durationMillis = 1000) + } else { + tween(durationMillis = 300) + }, label = "Capture Button Color" ) @@ -639,7 +644,7 @@ 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.Available.Recording.LockedRecording -> captureButtonSize.dp @@ -659,6 +664,31 @@ private fun CaptureButtonNucleus( }, animationSpec = tween(durationMillis = 500, easing = FastOutSlowInEasing) ) + + val pressTransition = updateTransition( + targetState = isPressed && + currentUiState.value.let { + it is CaptureButtonUiState.Available.Idle && it.captureMode == CaptureMode.IMAGE_ONLY + }, + label = "Press Size Transition" + ) + + val centerShapeSize by pressTransition.animateDp( + transitionSpec = { + if (targetState) { + snap() + } else { + tween(durationMillis = 200) + } + }, + label = "Nucleus Size" + ) { isPressedImage -> + if (isPressedImage) { + captureButtonSize.dp + } else { + standardShapeSize + } + } // used to fade between red/white in the center of the capture button val isPressableImageMode = currentUiState.value.let { @@ -678,6 +708,7 @@ private fun CaptureButtonNucleus( when { NucleusState.Disabled isTransitioningTo NucleusState.Idle -> tween(durationMillis = 300) NucleusState.Idle isTransitioningTo NucleusState.Disabled -> tween(durationMillis = 1000) + NucleusState.Pressed isTransitioningTo NucleusState.Idle -> tween(durationMillis = 100) else -> snap() } } From 539a2266c3544d999d73de116f7e7f994ef607b1 Mon Sep 17 00:00:00 2001 From: Trevor McGuire Date: Mon, 12 Jan 2026 19:21:39 -0800 Subject: [PATCH 05/10] Ensure capture animation works with volume buttons --- .../capture/CaptureButtonComponents.kt | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) 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 4ee8e7a67..5855e5d65 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 @@ -186,6 +186,9 @@ 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.Available.Idle) { onLockVideoRecording(false) @@ -221,6 +224,16 @@ 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() @@ -231,6 +244,18 @@ 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 From 828c02e0c83a446fd63278323a4a91b6a61b3c64 Mon Sep 17 00:00:00 2001 From: Trevor McGuire Date: Tue, 13 Jan 2026 17:21:31 -0800 Subject: [PATCH 06/10] Apply Spotless --- .../capture/CaptureButtonComponents.kt | 30 +++++++++++++------ 1 file changed, 21 insertions(+), 9 deletions(-) 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 5855e5d65..00a5b7d45 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 @@ -629,7 +629,9 @@ private fun LockSwitchCaptureButtonNucleus( } private enum class NucleusState { - Disabled, Idle, Pressed + Disabled, + Idle, + Pressed } /** @@ -689,11 +691,12 @@ private fun CaptureButtonNucleus( }, animationSpec = tween(durationMillis = 500, easing = FastOutSlowInEasing) ) - + val pressTransition = updateTransition( targetState = isPressed && currentUiState.value.let { - it is CaptureButtonUiState.Available.Idle && it.captureMode == CaptureMode.IMAGE_ONLY + it is CaptureButtonUiState.Available.Idle && + it.captureMode == CaptureMode.IMAGE_ONLY }, label = "Press Size Transition" ) @@ -718,7 +721,7 @@ private fun CaptureButtonNucleus( // used to fade between red/white in the center of the capture button val isPressableImageMode = currentUiState.value.let { it is CaptureButtonUiState.Available.Idle && - (it.captureMode == CaptureMode.IMAGE_ONLY || it.captureMode == CaptureMode.STANDARD) + (it.captureMode == CaptureMode.IMAGE_ONLY || it.captureMode == CaptureMode.STANDARD) } val nucleusState = when { isVisuallyDisabled -> NucleusState.Disabled @@ -726,14 +729,21 @@ private fun CaptureButtonNucleus( else -> NucleusState.Idle } - val transition = updateTransition(targetState = nucleusState, label = "Nucleus Color Transition") + 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 = 300) - NucleusState.Idle isTransitioningTo NucleusState.Disabled -> tween(durationMillis = 1000) - NucleusState.Pressed isTransitioningTo NucleusState.Idle -> tween(durationMillis = 100) + NucleusState.Disabled isTransitioningTo NucleusState.Idle -> tween( + durationMillis = 300 + ) + NucleusState.Idle isTransitioningTo NucleusState.Disabled -> tween( + durationMillis = 1000 + ) + NucleusState.Pressed isTransitioningTo NucleusState.Idle -> tween( + durationMillis = 100 + ) else -> snap() } } @@ -895,7 +905,9 @@ private fun PressedImageCaptureButtonPreview() { color = Color.White ) { CaptureButtonNucleus( - captureButtonUiState = CaptureButtonUiState.Available.Idle(CaptureMode.IMAGE_ONLY), + captureButtonUiState = CaptureButtonUiState.Available.Idle( + CaptureMode.IMAGE_ONLY + ), isPressed = true, captureButtonSize = DEFAULT_CAPTURE_BUTTON_SIZE ) From 940a090e6c6c2cf5778d31078a93c25f18acb2c9 Mon Sep 17 00:00:00 2001 From: Trevor McGuire Date: Tue, 13 Jan 2026 17:38:23 -0800 Subject: [PATCH 07/10] Adjust size of capture button, ring stroke, and nucleus to match mocks --- .../ui/components/capture/CaptureButtonComponents.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 00a5b7d45..670153339 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 @@ -90,7 +90,7 @@ 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 // scales against the size of the capture button private const val LOCK_SWITCH_PRESSED_NUCLEUS_SCALE = .5f @@ -519,7 +519,7 @@ fun CaptureButtonRing( modifier: Modifier = Modifier, captureButtonSize: Float, color: Color, - borderWidth: Float = 4f, + borderWidth: Float = 3f, contents: (@Composable () -> Unit)? = null ) { Box(modifier = modifier, contentAlignment = Alignment.Center) { @@ -653,8 +653,8 @@ private fun CaptureButtonNucleus( offsetX: Dp = 0.dp, recordingColor: Color = Color.Red, imageCaptureModeColor: Color = Color.White, - idleImageCaptureScale: Float = .7f, - idleVideoCaptureScale: Float = .35f, + idleImageCaptureScale: Float = .74f, + idleVideoCaptureScale: Float = .26f, pressedVideoCaptureScale: Float = .7f, isVisuallyDisabled: Boolean = false ) { From 098cc251ddc92b42b949de20550341402dcae8bb Mon Sep 17 00:00:00 2001 From: Trevor McGuire Date: Tue, 13 Jan 2026 17:49:32 -0800 Subject: [PATCH 08/10] Make capture button nucleus white when in idle VIDEO_ONLY mode --- .../ui/components/capture/CaptureButtonComponents.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 670153339..39b92d033 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 @@ -756,7 +756,8 @@ private fun CaptureButtonNucleus( is CaptureButtonUiState.Available.Idle -> when (uiState.captureMode) { CaptureMode.STANDARD -> imageCaptureModeColor CaptureMode.IMAGE_ONLY -> imageCaptureModeColor - CaptureMode.VIDEO_ONLY -> recordingColor + CaptureMode.VIDEO_ONLY -> + if (isPressed) recordingColor else imageCaptureModeColor } is CaptureButtonUiState.Available.Recording -> recordingColor From 6e8f7c35175ffc44610bc50fb15b62cb1d9a85c7 Mon Sep 17 00:00:00 2001 From: Trevor McGuire Date: Tue, 13 Jan 2026 17:58:06 -0800 Subject: [PATCH 09/10] Add 50% black background to capture button to match mocks --- .../ui/components/capture/CaptureButtonComponents.kt | 5 +++++ 1 file changed, 5 insertions(+) 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 39b92d033..8caafe8cf 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 @@ -523,6 +523,11 @@ fun CaptureButtonRing( contents: (@Composable () -> Unit)? = null ) { Box(modifier = modifier, contentAlignment = Alignment.Center) { + Box( + modifier = Modifier + .size(captureButtonSize.dp) + .background(Color.Black.copy(alpha = 0.5f), CircleShape) + ) contents?.invoke() // todo(): use a canvas instead of a box. // the sizing gets funny so the scales need to be completely readjusted From 647522bb7eb0468ff56b2e7f447640b26e0131a7 Mon Sep 17 00:00:00 2001 From: Trevor McGuire Date: Tue, 13 Jan 2026 18:30:12 -0800 Subject: [PATCH 10/10] Update compose previews to use a gradient background for higher vis --- .../capture/CaptureButtonComponents.kt | 76 +++++++++++++------ 1 file changed, 52 insertions(+), 24 deletions(-) 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 8caafe8cf..c89642d10 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 @@ -51,9 +51,9 @@ import androidx.compose.material.icons.filled.LockOpen import androidx.compose.material3.Icon import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface 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 @@ -72,6 +72,7 @@ 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 @@ -807,12 +808,7 @@ private fun CaptureButtonNucleus( @Preview @Composable private fun CaptureButtonUnavailablePreview() { - CaptureButton( - onImageCapture = {}, - onStartRecording = {}, - onStopRecording = {}, - onLockVideoRecording = {}, - onIncrementZoom = {}, + PreviewCaptureButton( captureButtonUiState = CaptureButtonUiState.Unavailable ) } @@ -825,8 +821,16 @@ private fun PreviewCaptureButton( interactionSource: MutableInteractionSource = remember { MutableInteractionSource() } ) { MaterialTheme(colorScheme = darkColorScheme()) { - Surface(color = Color.Black, contentColor = Color.White, modifier = modifier) { - Box(contentAlignment = contentAlignment) { + CompositionLocalProvider(LocalContentColor provides Color.White) { + Box( + modifier = modifier + .background( + Brush.verticalGradient( + colors = listOf(Color.Gray, Color.DarkGray) + ) + ), + contentAlignment = contentAlignment + ) { CaptureButton( modifier = Modifier, onImageCapture = {}, @@ -904,20 +908,26 @@ private fun IdleVideoOnlyCaptureButtonDisabledPreview() { private fun PressedImageCaptureButtonPreview() { // Manually constructed preview to verify visual state without relying on interaction source MaterialTheme(colorScheme = darkColorScheme()) { - Surface(color = Color.Black, contentColor = Color.White) { - Box(contentAlignment = Alignment.Center) { - CaptureButtonRing( - captureButtonSize = DEFAULT_CAPTURE_BUTTON_SIZE, - color = Color.White - ) { - CaptureButtonNucleus( - captureButtonUiState = CaptureButtonUiState.Available.Idle( - CaptureMode.IMAGE_ONLY - ), - isPressed = true, - captureButtonSize = DEFAULT_CAPTURE_BUTTON_SIZE + Box( + modifier = Modifier + .background( + Brush.verticalGradient( + colors = listOf(Color.Gray, Color.DarkGray) ) - } + ), + contentAlignment = Alignment.Center + ) { + CaptureButtonRing( + captureButtonSize = DEFAULT_CAPTURE_BUTTON_SIZE, + color = Color.White + ) { + CaptureButtonNucleus( + captureButtonUiState = CaptureButtonUiState.Available.Idle( + CaptureMode.IMAGE_ONLY + ), + isPressed = true, + captureButtonSize = DEFAULT_CAPTURE_BUTTON_SIZE + ) } } } @@ -946,7 +956,16 @@ private fun LockedRecordingPreview() { @Composable private fun LockSwitchLockedAtThresholdPressedRecordingPreview() { // box is here to account for the offset lock switch - Box(modifier = Modifier.width(150.dp), contentAlignment = Alignment.CenterEnd) { + 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.White) { LockSwitchCaptureButtonNucleus( captureButtonSize = DEFAULT_CAPTURE_BUTTON_SIZE, @@ -965,7 +984,16 @@ private fun LockSwitchLockedAtThresholdPressedRecordingPreview() { @Composable private fun LockSwitchLockedPressedRecordingPreview() { // box is here to account for the offset lock switch - Box(modifier = Modifier.width(150.dp), contentAlignment = Alignment.CenterEnd) { + 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.White) { LockSwitchCaptureButtonNucleus( captureButtonSize = DEFAULT_CAPTURE_BUTTON_SIZE,