From b36f4757212c3bd452c8c5cd5308d288e1e5ace8 Mon Sep 17 00:00:00 2001 From: Kimberly Crevecoeur Date: Fri, 6 Nov 2026 07:52:21 -0800 Subject: [PATCH 1/5] feat(ui/capture): add content gravity rotation for 1:1 glyphs and rectangular overlays --- .../components/capture/CameraErrorDialog.kt | 28 +- .../capture/CaptureButtonComponents.kt | 5 +- .../ui/components/capture/CaptureLayout.kt | 14 +- .../capture/CaptureScreenComponents.kt | 14 +- .../capture/CompactPortraitLayout.kt | 204 ++++- .../ui/components/capture/ImageWell.kt | 4 +- .../components/capture/RotationModifiers.kt | 103 ++- .../components/capture/TwoSideRailLayout.kt | 5 +- .../ui/components/capture/WideShortLayout.kt | 95 ++- .../quicksettings/QuickSettingsScreen.kt | 39 +- .../ui/QuickSettingsComponents.kt | 10 +- .../components/capture/ContentGravityTest.kt | 784 ++++++++++++++++++ 12 files changed, 1197 insertions(+), 108 deletions(-) create mode 100644 ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/ContentGravityTest.kt diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CameraErrorDialog.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CameraErrorDialog.kt index eeedec2c5d..dfb9bd8a15 100644 --- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CameraErrorDialog.kt +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CameraErrorDialog.kt @@ -45,6 +45,16 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +private object CameraErrorDialogTokens { + val CardHorizontalMargin = 24.dp + val CardMinWidth = 280.dp + val CardMaxWidth = 560.dp + val ContentPadding = 24.dp + val IconSize = 24.dp + val SectionSpacing = 16.dp + val ButtonTopSpacing = 24.dp +} + /** * In-hierarchy dialog for camera errors. Callers provide the copy. * @@ -85,8 +95,12 @@ fun CameraErrorDialog( ) { Surface( modifier = Modifier - .padding(horizontal = 24.dp) - .widthIn(min = 280.dp, max = 560.dp) + .rotateLayoutToGravity() + .padding(horizontal = CameraErrorDialogTokens.CardHorizontalMargin) + .widthIn( + min = CameraErrorDialogTokens.CardMinWidth, + max = CameraErrorDialogTokens.CardMaxWidth + ) .pointerInput(Unit) { detectTapGestures { /* Consume taps on the dialog card */ } }, @@ -95,16 +109,16 @@ fun CameraErrorDialog( tonalElevation = AlertDialogDefaults.TonalElevation ) { Column( - modifier = Modifier.padding(24.dp), + modifier = Modifier.padding(CameraErrorDialogTokens.ContentPadding), horizontalAlignment = Alignment.CenterHorizontally ) { Icon( painter = painterResource(R.drawable.ic_videocam_outline), contentDescription = null, tint = MaterialTheme.colorScheme.secondary, - modifier = Modifier.size(24.dp) + modifier = Modifier.size(CameraErrorDialogTokens.IconSize) ) - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(CameraErrorDialogTokens.SectionSpacing)) Text( text = title, style = MaterialTheme.typography.headlineSmall, @@ -115,7 +129,7 @@ fun CameraErrorDialog( .testTag(CAMERA_ERROR_DIALOG_TITLE_TAG) ) body?.let { - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(CameraErrorDialogTokens.SectionSpacing)) Text( text = it, style = MaterialTheme.typography.bodyMedium, @@ -125,7 +139,7 @@ fun CameraErrorDialog( .testTag(CAMERA_ERROR_DIALOG_BODY_TAG) ) } - Spacer(modifier = Modifier.height(24.dp)) + Spacer(modifier = Modifier.height(CameraErrorDialogTokens.ButtonTopSpacing)) Box( modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd 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 e6c3179871..4831579fe2 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 @@ -813,7 +813,10 @@ private fun LockSwitchCaptureButtonNucleus( } ) { Icon( - modifier = Modifier.size(switchHeight * .75f).align(Alignment.Center), + modifier = Modifier + .size(switchHeight * .75f) + .align(Alignment.Center) + .animateToUpright(), tint = Color.White, painter = if (shouldBeLocked()) { painterResource(R.drawable.ic_lock) 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 3226eff11c..d5086cb66c 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 @@ -179,8 +179,20 @@ fun PreviewLayout( ) val isQuickSettingsOpen = scaffoldState.bottomSheetState.isVisible || scaffoldState.bottomSheetState.targetValue == SheetValue.Expanded + val isCompactPortraitBacked = layoutMode == CameraLayoutMode.COMPACT_PORTRAIT || + layoutMode == CameraLayoutMode.COMPACT_HORIZONTAL || + layoutMode == CameraLayoutMode.COMPACT_SLIM + val modeGravityRotationState = + if (isCompactPortraitBacked) { + gravityRotationState + } else { + DefaultGravityRotationState + } - CompositionLocalProvider(LocalCameraLayoutMode provides layoutMode) { + CompositionLocalProvider( + LocalCameraLayoutMode provides layoutMode, + LocalGravityRotationState provides modeGravityRotationState + ) { when (layoutMode) { CameraLayoutMode.COMPACT_PORTRAIT, CameraLayoutMode.COMPACT_HORIZONTAL, 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 93bc296dca..0cbcff4385 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 @@ -510,7 +510,9 @@ fun PauseResumeToggleButton( currentRecordingState !is VideoRecordingState.Active.Paused ) }, - modifier = modifier.size(size) + modifier = modifier + .size(size) + .animateToUpright() ) { Icon( modifier = Modifier @@ -566,6 +568,7 @@ fun AmplitudeToggleButton( FilledIconToggleButton( modifier = modifier .size(buttonSize) + .animateToUpright() .testTag(AUDIO_INPUT_TOGGLE) .semantics { stateDescription = if (audioUiState is AudioUiState.Enabled.On) { @@ -1240,7 +1243,9 @@ fun StabilizationIcon(stabilizationUiState: StabilizationUiState, modifier: Modi ) { if (displayedState != null) { Icon( - modifier = Modifier.size(IconButtonDefaults.smallIconSize), + modifier = Modifier + .size(IconButtonDefaults.smallIconSize) + .animateToUpright(), painter = when (displayedState) { is StabilizationUiState.Specific -> when (displayedState.stabilizationMode) { @@ -1325,7 +1330,9 @@ fun VideoQualityIcon(videoQuality: VideoQuality, modifier: Modifier = Modifier) ) { if (displayedQuality != null) { Icon( - modifier = Modifier.size(IconButtonDefaults.smallIconSize), + modifier = Modifier + .size(IconButtonDefaults.smallIconSize) + .animateToUpright(), painter = when (displayedQuality) { VideoQuality.SD -> painterResource(R.drawable.video_resolution_sd_icon) @@ -1519,6 +1526,7 @@ fun FlipCameraButton( contentDescription = contentDescription, modifier = Modifier .size(IconButtonDefaults.largeIconSize) + .animateToUpright() .rotate(animatedRotation.value) ) } diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CompactPortraitLayout.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CompactPortraitLayout.kt index 1a63f8c849..c33246568b 100644 --- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CompactPortraitLayout.kt +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CompactPortraitLayout.kt @@ -15,12 +15,17 @@ */ package com.google.jetpackcamera.ui.components.capture +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi @@ -34,6 +39,7 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.navigationBarsIgnoringVisibility import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding @@ -56,15 +62,20 @@ import androidx.compose.material3.rememberBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.AbsoluteAlignment import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.AlignmentLine import androidx.compose.ui.layout.Layout import androidx.compose.ui.layout.Placeable @@ -88,6 +99,7 @@ import com.google.jetpackcamera.ui.components.capture.layout.CameraLayoutSolver import com.google.jetpackcamera.ui.components.capture.layout.CameraRowIds import com.google.jetpackcamera.ui.components.capture.layout.CameraWindow import com.google.jetpackcamera.ui.components.capture.layout.LocalCameraLayoutSolution +import kotlinx.coroutines.delay private object CompactPortraitTokens { val SheetPeekHeight = 0.dp @@ -95,6 +107,21 @@ private object CompactPortraitTokens { val DefaultMinTouchTarget = 48.dp val ZeroInset = 0.dp + /** Maximum portrait-space height (landscape width) of the Quick Settings bottom sheet. */ + val LandscapeMaxSheetHeight = 380.dp + + /** Cross-fade duration when rotating the Quick Settings sheet while open. */ + const val QUICK_SETTINGS_FADE_DURATION_MS = 150 + + /** Sheet container resize duration when rotating the Quick Settings sheet while open. */ + const val QUICK_SETTINGS_RESIZE_DURATION_MS = 250 + + /** Clockwise gravity degrees corresponding to [DeviceRotation.Rotated270]. */ + const val LANDSCAPE_CCW_GRAVITY_DEGREES = 90 + + /** Clockwise gravity degrees corresponding to [DeviceRotation.Rotated90]. */ + const val LANDSCAPE_CW_GRAVITY_DEGREES = 270 + /** * Height of the shutter row (capture button, image well and flip camera button) as specified * by the design spec. @@ -465,6 +492,7 @@ fun CompactPortraitLayout( ) { val overlapTargetBounds = remember { mutableStateOf(Rect.Zero) } val localeDirection = LocalLayoutDirection.current + val disableAnimations = LocalDisableAnimations.current val dismissQuickSettingsLabel = stringResource(R.string.quick_settings_btn_close_expanded_settings_description) val isSheetVisible = scaffoldState.bottomSheetState.isVisible || @@ -508,6 +536,48 @@ fun CompactPortraitLayout( deviceRotation = effectiveDeviceRotation, windowRotation = containerFrameRotation ) + val targetGravityDegrees = + effectiveDeviceRotation.toGravityRotationDegrees(containerFrameRotation) + val isLandscapeGravity = + targetGravityDegrees == CompactPortraitTokens.LANDSCAPE_CCW_GRAVITY_DEGREES || + targetGravityDegrees == CompactPortraitTokens.LANDSCAPE_CW_GRAVITY_DEGREES + + var displayedSheetGravityDegrees by remember { mutableIntStateOf(targetGravityDegrees) } + val sheetContentAlpha = remember { Animatable(1f) } + if (displayedSheetGravityDegrees != targetGravityDegrees && + (!isSheetVisible || disableAnimations) + ) { + displayedSheetGravityDegrees = targetGravityDegrees + } + + LaunchedEffect(targetGravityDegrees, isSheetVisible, disableAnimations) { + if (displayedSheetGravityDegrees != targetGravityDegrees) { + if (isSheetVisible && !disableAnimations) { + sheetContentAlpha.animateTo( + targetValue = 0f, + animationSpec = tween( + durationMillis = CompactPortraitTokens.QUICK_SETTINGS_FADE_DURATION_MS, + easing = FastOutSlowInEasing + ) + ) + displayedSheetGravityDegrees = targetGravityDegrees + delay(CompactPortraitTokens.QUICK_SETTINGS_RESIZE_DURATION_MS.toLong()) + sheetContentAlpha.animateTo( + targetValue = 1f, + animationSpec = tween( + durationMillis = CompactPortraitTokens.QUICK_SETTINGS_FADE_DURATION_MS, + easing = FastOutSlowInEasing + ) + ) + } else { + displayedSheetGravityDegrees = targetGravityDegrees + sheetContentAlpha.snapTo(1f) + } + } else if (sheetContentAlpha.value < 1f && (!isSheetVisible || disableAnimations)) { + sheetContentAlpha.snapTo(1f) + } + } + val sheetGravityRotationState = rememberUpdatedState(displayedSheetGravityDegrees.toFloat()) CompositionLocalProvider( LocalOverlapTargetBounds provides overlapTargetBounds, @@ -541,8 +611,56 @@ fun CompactPortraitLayout( ), sheetContainerColor = MaterialTheme.colorScheme.surfaceContainerLow, sheetContent = { - CompositionLocalProvider(LocalLayoutDirection provides localeDirection) { - quickSettingsOverlay(Modifier) + val isDisplayedSheetLandscape = + displayedSheetGravityDegrees == + CompactPortraitTokens.LANDSCAPE_CCW_GRAVITY_DEGREES || + displayedSheetGravityDegrees == + CompactPortraitTokens.LANDSCAPE_CW_GRAVITY_DEGREES + CompositionLocalProvider( + LocalLayoutDirection provides localeDirection, + LocalGravityRotationState provides sheetGravityRotationState + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .then( + if (disableAnimations) { + Modifier + } else { + Modifier.animateContentSize( + animationSpec = tween( + durationMillis = + CompactPortraitTokens + .QUICK_SETTINGS_RESIZE_DURATION_MS, + easing = FastOutSlowInEasing + ) + ) + } + ) + .graphicsLayer { + alpha = sheetContentAlpha.value + } + ) { + if (isDisplayedSheetLandscape) { + Box( + modifier = Modifier + .fillMaxWidth() + .heightIn( + max = CompactPortraitTokens.LandscapeMaxSheetHeight + ) + .rotateLayoutToGravity(displayedSheetGravityDegrees), + contentAlignment = Alignment.TopStart + ) { + CompositionLocalProvider( + LocalLayoutDirection provides localeDirection + ) { + quickSettingsOverlay(Modifier) + } + } + } else { + quickSettingsOverlay(Modifier) + } + } } }, snackbarHost = {} @@ -556,6 +674,23 @@ fun CompactPortraitLayout( val isHandheldPortrait = windowAspectRatio >= CompactPortraitTokens.PORTRAIT_ASPECT_RATIO_THRESHOLD + val bottomElapsedTimeDisplay: @Composable (Modifier) -> Unit = + if (isLandscapeGravity) { + {} + } else { + elapsedTimeDisplay + } + val bottomSnackbarHost: @Composable (Modifier) -> Unit = + if (isLandscapeGravity) { + {} + } else { + { hostModifier -> + PillSnackbarHost( + snackbarHostState = scaffoldState.snackbarHostState, + modifier = hostModifier + ) + } + } if (isHandheldPortrait) { AdaptivePortraitCaptureLayout( windowWidth = portraitWidth, @@ -570,7 +705,7 @@ fun CompactPortraitLayout( onRequiresImmersiveChange = onRequiresImmersiveChange, indicatorRow = indicatorRow, topBarTrailingContent = topBarTrailingContent, - elapsedTimeDisplay = elapsedTimeDisplay, + elapsedTimeDisplay = bottomElapsedTimeDisplay, zoomControls = zoomLevelDisplay, imageWell = imageWell, captureButton = captureButton, @@ -579,12 +714,7 @@ fun CompactPortraitLayout( quickSettingsToggleButton = quickSettingsButton, captureModeToggleSwitch = captureModeToggle, debugVisibilityWrapper = debugVisibilityWrapper, - snackbarHost = { hostModifier -> - PillSnackbarHost( - snackbarHostState = scaffoldState.snackbarHostState, - modifier = hostModifier - ) - } + snackbarHost = bottomSnackbarHost ) Box( modifier = Modifier @@ -596,6 +726,15 @@ fun CompactPortraitLayout( .toPortraitWindowInsets(effectiveWindowRotation) ) ) { + if (isLandscapeGravity) { + LandscapeTopEdgeBanner( + targetGravityDegrees = targetGravityDegrees, + localeDirection = localeDirection, + snackbarHostState = scaffoldState.snackbarHostState, + elapsedTimeDisplay = elapsedTimeDisplay, + debugVisibilityWrapper = debugVisibilityWrapper + ) + } CompositionLocalProvider( LocalLayoutDirection provides localeDirection ) { @@ -629,6 +768,7 @@ fun CompactPortraitLayout( Modifier .align(Alignment.TopStart) .suppressBackgroundForModal(isSheetVisible) + .animateToUpright() ) { topStartContent(Modifier) } @@ -662,13 +802,17 @@ fun CompactPortraitLayout( captureModeToggleSwitch = captureModeToggle, captureModeCarousel = captureModeCarousel, zoomControls = zoomLevelDisplay, + elapsedTimeDisplay = bottomElapsedTimeDisplay, + snackbarHost = bottomSnackbarHost + ) + } + if (isLandscapeGravity) { + LandscapeTopEdgeBanner( + targetGravityDegrees = targetGravityDegrees, + localeDirection = localeDirection, + snackbarHostState = scaffoldState.snackbarHostState, elapsedTimeDisplay = elapsedTimeDisplay, - snackbarHost = { hostModifier -> - PillSnackbarHost( - snackbarHostState = scaffoldState.snackbarHostState, - modifier = hostModifier - ) - } + debugVisibilityWrapper = debugVisibilityWrapper ) } CompositionLocalProvider( @@ -710,6 +854,35 @@ fun CompactPortraitLayout( } } +@Composable +private fun BoxScope.LandscapeTopEdgeBanner( + targetGravityDegrees: Int, + localeDirection: LayoutDirection, + snackbarHostState: SnackbarHostState, + elapsedTimeDisplay: @Composable (Modifier) -> Unit, + debugVisibilityWrapper: @Composable (@Composable () -> Unit) -> Unit +) { + val landscapeTopAlignment = + if (targetGravityDegrees == CompactPortraitTokens.LANDSCAPE_CCW_GRAVITY_DEGREES) { + AbsoluteAlignment.CenterRight + } else { + AbsoluteAlignment.CenterLeft + } + debugVisibilityWrapper { + CompositionLocalProvider(LocalLayoutDirection provides localeDirection) { + Column( + modifier = Modifier + .align(landscapeTopAlignment) + .rotateLayoutToGravity(targetGravityDegrees), + horizontalAlignment = Alignment.CenterHorizontally + ) { + PillSnackbarHost(snackbarHostState = snackbarHostState) + elapsedTimeDisplay(Modifier) + } + } + } +} + @Composable private fun computeTopBarMetrics( includeStatusBarsInTopBar: Boolean, @@ -963,6 +1136,7 @@ internal fun AdaptivePortraitCaptureLayout( modifier = Modifier .layoutId(CaptureSlotId.TopStartContent) .suppressBackgroundForModal(isSheetVisible) + .animateToUpright() ) { OverlapAwareStyleProvider { topStartContent(Modifier) diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/ImageWell.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/ImageWell.kt index 2f1a48a6fc..16028ddbb5 100644 --- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/ImageWell.kt +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/ImageWell.kt @@ -135,7 +135,9 @@ fun ImageWell( val imageBitmap = remember(bitmap) { bitmap.asImageBitmap() } Image( bitmap = imageBitmap, - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize() + .animateToUpright(), contentDescription = stringResource( id = R.string.image_well_content_description ), diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/RotationModifiers.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/RotationModifiers.kt index 23262062c8..4d9c0c5af0 100644 --- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/RotationModifiers.kt +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/RotationModifiers.kt @@ -37,6 +37,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.ProvidableCompositionLocal import androidx.compose.runtime.State import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf @@ -55,9 +56,18 @@ import androidx.compose.ui.unit.LayoutDirection import androidx.core.content.ContextCompat import androidx.lifecycle.compose.LifecycleStartEffect import com.google.jetpackcamera.model.DeviceRotation +import kotlin.math.roundToInt private const val UI_ROTATION_DURATION_MS = 250 -private val DefaultGravityRotationState: State = mutableFloatStateOf(0f) +private const val QUADRANT_DEGREES = 90 +private const val FULL_CIRCLE_DEGREES = 360 + +/** + * Shared zero-degree (`0f`) gravity rotation [State] used as the default for + * [LocalGravityRotationState] and bound explicitly by adaptive side-rail layouts that stay + * naturally oriented with the OS window. + */ +internal val DefaultGravityRotationState: State = mutableFloatStateOf(0f) /** * Provides the current physical gravity [DeviceRotation] to descendant composables in the capture @@ -186,6 +196,34 @@ fun Modifier.animateToUpright(rotationState: State? = null): Modifier = c } } +/** + * Snaps an arbitrary continuous clockwise [degrees] angle to the nearest 90-degree quadrant + * (`0`, `90`, `180`, or `270`). + */ +internal fun snapToQuadrantDegrees(degrees: Float): Int { + if (!degrees.isFinite()) return 0 + val quadrants = (degrees / QUADRANT_DEGREES.toFloat()).roundToInt() + return ((quadrants * QUADRANT_DEGREES) % FULL_CIRCLE_DEGREES + FULL_CIRCLE_DEGREES) % + FULL_CIRCLE_DEGREES +} + +/** + * Measures and places a rectangular composable with swapped width and height constraints when + * [LocalGravityRotationState] snaps to `90°` or `270°`, centering the rotated layer within the + * swapped layout box so rectangular overlays orient upright without clipping or misaligned hit + * targets. + * + * When [LocalGravityRotationState] snaps to `0°` (such as in portrait or in adaptive side-rail + * layouts), constraints and placement remain unrotated. + */ +fun Modifier.rotateLayoutToGravity(): Modifier = composed { + val gravityState = LocalGravityRotationState.current + val snappedDegrees by remember(gravityState) { + derivedStateOf { snapToQuadrantDegrees(gravityState.value) } + } + rotateLayoutToGravity(snappedDegrees) +} + /** * Measures and places a rectangular composable with swapped width and height constraints when * [rotationDegrees] is `90°` or `270°`, centering the rotated layer within the swapped layout box @@ -193,36 +231,45 @@ fun Modifier.animateToUpright(rotationState: State? = null): Modifier = c */ fun Modifier.rotateLayoutToGravity(rotationDegrees: Int): Modifier = layout { measurable, constraints -> - val normalized = ((rotationDegrees % 360) + 360) % 360 - val swapAxes = normalized == 90 || normalized == 270 - val childConstraints = if (swapAxes) { - val swappedMaxWidth = if (constraints.hasBoundedHeight) { - constraints.maxHeight - } else { - constraints.maxWidth + val normalized = + ((rotationDegrees % FULL_CIRCLE_DEGREES) + FULL_CIRCLE_DEGREES) % FULL_CIRCLE_DEGREES + if (normalized == 0) { + val placeable = measurable.measure(constraints) + layout(placeable.width, placeable.height) { + placeable.place(0, 0) } - val swappedMaxHeight = if (constraints.hasBoundedWidth) { - constraints.maxWidth + } else { + val swapAxes = + normalized == QUADRANT_DEGREES || normalized == QUADRANT_DEGREES * 3 + val childConstraints = if (swapAxes) { + val swappedMaxWidth = if (constraints.hasBoundedHeight) { + constraints.maxHeight + } else { + constraints.maxWidth + } + val swappedMaxHeight = if (constraints.hasBoundedWidth) { + constraints.maxWidth + } else { + constraints.maxHeight + } + Constraints( + minWidth = constraints.minHeight.coerceAtMost(swappedMaxWidth), + maxWidth = swappedMaxWidth, + minHeight = constraints.minWidth.coerceAtMost(swappedMaxHeight), + maxHeight = swappedMaxHeight + ) } else { - constraints.maxHeight + constraints } - Constraints( - minWidth = constraints.minHeight.coerceAtMost(swappedMaxWidth), - maxWidth = swappedMaxWidth, - minHeight = constraints.minWidth.coerceAtMost(swappedMaxHeight), - maxHeight = swappedMaxHeight - ) - } else { - constraints - } - val placeable = measurable.measure(childConstraints) - val layoutWidth = if (swapAxes) placeable.height else placeable.width - val layoutHeight = if (swapAxes) placeable.width else placeable.height - layout(layoutWidth, layoutHeight) { - val offsetX = (layoutWidth - placeable.width) / 2 - val offsetY = (layoutHeight - placeable.height) / 2 - placeable.placeWithLayer(offsetX, offsetY) { - rotationZ = normalized.toFloat() + val placeable = measurable.measure(childConstraints) + val layoutWidth = if (swapAxes) placeable.height else placeable.width + val layoutHeight = if (swapAxes) placeable.width else placeable.height + layout(layoutWidth, layoutHeight) { + val offsetX = (layoutWidth - placeable.width) / 2 + val offsetY = (layoutHeight - placeable.height) / 2 + placeable.placeWithLayer(offsetX, offsetY) { + rotationZ = normalized.toFloat() + } } } } diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/TwoSideRailLayout.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/TwoSideRailLayout.kt index 067ff3737e..44b86a5377 100644 --- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/TwoSideRailLayout.kt +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/TwoSideRailLayout.kt @@ -177,7 +177,10 @@ internal fun SideRailCaptureScaffold( BackHandler(enabled = isQuickSettingsOpen, onBack = onDismissQuickSettings) - CompositionLocalProvider(LocalOverlapTargetBounds provides overlapTargetBounds) { + CompositionLocalProvider( + LocalOverlapTargetBounds provides overlapTargetBounds, + LocalGravityRotationState provides DefaultGravityRotationState + ) { Box( modifier = modifier .fillMaxSize() diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/WideShortLayout.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/WideShortLayout.kt index 02f8188556..cc3f01f37d 100644 --- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/WideShortLayout.kt +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/WideShortLayout.kt @@ -112,55 +112,60 @@ fun WideShortLayout( val localeDirection = LocalLayoutDirection.current val safePadding = WindowInsets.systemBars.union(WindowInsets.displayCutout).asPaddingValues() - BoxWithConstraints(modifier = modifier.fillMaxSize()) { - val horizontalInsets = safePadding.calculateLeftPadding(localeDirection) + - safePadding.calculateRightPadding(localeDirection) - val verticalInsets = safePadding.calculateTopPadding() + - safePadding.calculateBottomPadding() - val availableWidth = (maxWidth - horizontalInsets).coerceAtLeast(WideShortTokens.ZeroSize) - val availableHeight = (maxHeight - verticalInsets).coerceAtLeast(WideShortTokens.ZeroSize) + CompositionLocalProvider(LocalGravityRotationState provides DefaultGravityRotationState) { + BoxWithConstraints(modifier = modifier.fillMaxSize()) { + val horizontalInsets = safePadding.calculateLeftPadding(localeDirection) + + safePadding.calculateRightPadding(localeDirection) + val verticalInsets = safePadding.calculateTopPadding() + + safePadding.calculateBottomPadding() + val availableWidth = + (maxWidth - horizontalInsets).coerceAtLeast(WideShortTokens.ZeroSize) + val availableHeight = + (maxHeight - verticalInsets).coerceAtLeast(WideShortTokens.ZeroSize) - val railSlotMaxWidth = min( - CaptureSlotTokens.ModeToggleCompactWidth, - (availableWidth * WideShortTokens.RAIL_WIDTH_FRACTION) - .coerceAtLeast(WideShortTokens.MinRailSlotWidthFloor) - ) - val estimatedRailFootprint = railSlotMaxWidth + WideShortTokens.RailHorizontalPaddingTotal - val condensedSlots = remember(slots, availableWidth, availableHeight) { - slots.withCondensedSizeCaps( - availableWidth = availableWidth, - availableHeight = availableHeight, - railSlotMaxWidth = railSlotMaxWidth, - estimatedRailFootprint = estimatedRailFootprint + val railSlotMaxWidth = min( + CaptureSlotTokens.ModeToggleCompactWidth, + (availableWidth * WideShortTokens.RAIL_WIDTH_FRACTION) + .coerceAtLeast(WideShortTokens.MinRailSlotWidthFloor) ) - } - val cardMaxWidth = min( - WideShortTokens.QuickSettingsCardMaxWidth, - (availableWidth - WideShortTokens.CondensedChromePadding * 2) + val estimatedRailFootprint = + railSlotMaxWidth + WideShortTokens.RailHorizontalPaddingTotal + val condensedSlots = remember(slots, availableWidth, availableHeight) { + slots.withCondensedSizeCaps( + availableWidth = availableWidth, + availableHeight = availableHeight, + railSlotMaxWidth = railSlotMaxWidth, + estimatedRailFootprint = estimatedRailFootprint + ) + } + val cardMaxWidth = min( + WideShortTokens.QuickSettingsCardMaxWidth, + (availableWidth - WideShortTokens.CondensedChromePadding * 2) + .coerceAtLeast(WideShortTokens.ZeroSize) + ) + val cardMaxHeight = (availableHeight - WideShortTokens.CondensedChromePadding * 2) .coerceAtLeast(WideShortTokens.ZeroSize) - ) - val cardMaxHeight = (availableHeight - WideShortTokens.CondensedChromePadding * 2) - .coerceAtLeast(WideShortTokens.ZeroSize) - SideRailCaptureScaffold( - slots = condensedSlots, - viewfinder = viewfinder, - modifier = Modifier.fillMaxSize(), - snackbarHostState = snackbarHostState, - isCompactHeight = true, - chromePadding = WideShortTokens.CondensedChromePadding, - topOverlayHorizontalClearance = estimatedRailFootprint, - quickSettingsCardMaxWidth = cardMaxWidth, - quickSettingsCardMaxHeight = cardMaxHeight, - isQuickSettingsOpen = isQuickSettingsOpen, - isRecording = isRecording, - isErrorDialogShowing = isErrorDialogShowing, - tagQuickSettingsCard = tagQuickSettingsCard, - onDismissQuickSettings = onDismissQuickSettings, - debugOverlay = debugOverlay, - debugVisibilityWrapper = debugVisibilityWrapper, - screenFlashOverlay = screenFlashOverlay - ) + SideRailCaptureScaffold( + slots = condensedSlots, + viewfinder = viewfinder, + modifier = Modifier.fillMaxSize(), + snackbarHostState = snackbarHostState, + isCompactHeight = true, + chromePadding = WideShortTokens.CondensedChromePadding, + topOverlayHorizontalClearance = estimatedRailFootprint, + quickSettingsCardMaxWidth = cardMaxWidth, + quickSettingsCardMaxHeight = cardMaxHeight, + isQuickSettingsOpen = isQuickSettingsOpen, + isRecording = isRecording, + isErrorDialogShowing = isErrorDialogShowing, + tagQuickSettingsCard = tagQuickSettingsCard, + onDismissQuickSettings = onDismissQuickSettings, + debugOverlay = debugOverlay, + debugVisibilityWrapper = debugVisibilityWrapper, + screenFlashOverlay = screenFlashOverlay + ) + } } } diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/quicksettings/QuickSettingsScreen.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/quicksettings/QuickSettingsScreen.kt index 75447f5daa..90d275a5f7 100644 --- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/quicksettings/QuickSettingsScreen.kt +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/quicksettings/QuickSettingsScreen.kt @@ -22,6 +22,8 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBarsIgnoringVisibility import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -37,6 +39,7 @@ import com.google.jetpackcamera.model.DynamicRange import com.google.jetpackcamera.model.FlashMode import com.google.jetpackcamera.model.ImageOutputFormat import com.google.jetpackcamera.model.LensFacing +import com.google.jetpackcamera.ui.components.capture.LocalGravityRotationState import com.google.jetpackcamera.ui.components.capture.QUICK_SETTINGS_BOTTOM_SHEET import com.google.jetpackcamera.ui.components.capture.R import com.google.jetpackcamera.ui.components.capture.quicksettings.ui.AspectRatioRow @@ -44,6 +47,7 @@ import com.google.jetpackcamera.ui.components.capture.quicksettings.ui.CaptureMo import com.google.jetpackcamera.ui.components.capture.quicksettings.ui.FlashRow import com.google.jetpackcamera.ui.components.capture.quicksettings.ui.HdrRow import com.google.jetpackcamera.ui.components.capture.quicksettings.ui.QuickNavSettings +import com.google.jetpackcamera.ui.components.capture.snapToQuadrantDegrees import com.google.jetpackcamera.ui.controller.quicksettings.QuickSettingsController import com.google.jetpackcamera.ui.uistate.SingleSelectableUiState import com.google.jetpackcamera.ui.uistate.capture.AspectRatioUiState @@ -53,6 +57,17 @@ import com.google.jetpackcamera.ui.uistate.capture.FlipLensUiState import com.google.jetpackcamera.ui.uistate.capture.HdrUiState import com.google.jetpackcamera.ui.uistate.capture.compound.QuickSettingsUiState +private object QuickSettingsScreenTokens { + val ZeroPadding = 0.dp + val LandscapeTopPadding = 24.dp + val BottomPadding = 24.dp + val HeaderHorizontalPadding = 16.dp + val HeaderBottomPadding = 8.dp + val MoreSettingsTopPadding = 12.dp + const val LANDSCAPE_CCW_GRAVITY_DEGREES = 90 + const val LANDSCAPE_CW_GRAVITY_DEGREES = 270 +} + /** * Agnostic content for the Quick Settings panel wrapped for a BottomSheetScaffold sheet. * @@ -72,12 +87,24 @@ fun QuickSettingsScaffoldContent( showMoreSettingsButton: Boolean = true ) { if (quickSettingsUiState is QuickSettingsUiState.Available) { + val gravityDegrees = snapToQuadrantDegrees(LocalGravityRotationState.current.value) + val isLandscapeSheet = + gravityDegrees == QuickSettingsScreenTokens.LANDSCAPE_CCW_GRAVITY_DEGREES || + gravityDegrees == QuickSettingsScreenTokens.LANDSCAPE_CW_GRAVITY_DEGREES Column( modifier = modifier .fillMaxWidth() - .windowInsetsPadding(WindowInsets.navigationBarsIgnoringVisibility) - .padding(bottom = 24.dp) .testTag(QUICK_SETTINGS_BOTTOM_SHEET) + .verticalScroll(rememberScrollState()) + .windowInsetsPadding(WindowInsets.navigationBarsIgnoringVisibility) + .padding( + top = if (isLandscapeSheet) { + QuickSettingsScreenTokens.LandscapeTopPadding + } else { + QuickSettingsScreenTokens.ZeroPadding + }, + bottom = QuickSettingsScreenTokens.BottomPadding + ) ) { QuickSettingsContent( quickSettingsUiState = quickSettingsUiState, @@ -118,7 +145,11 @@ internal fun QuickSettingsContent( Column(modifier = modifier.fillMaxWidth()) { Text( - modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 8.dp), + modifier = Modifier.padding( + start = QuickSettingsScreenTokens.HeaderHorizontalPadding, + end = QuickSettingsScreenTokens.HeaderHorizontalPadding, + bottom = QuickSettingsScreenTokens.HeaderBottomPadding + ), text = stringResource(id = titleRes), style = MaterialTheme.typography.titleLarge ) @@ -171,7 +202,7 @@ internal fun QuickSettingsContent( if (showMoreSettingsButton) { QuickNavSettings( onNavigateToSettings = onNavigateToSettings, - modifier = Modifier.padding(top = 12.dp) + modifier = Modifier.padding(top = QuickSettingsScreenTokens.MoreSettingsTopPadding) ) } } diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/quicksettings/ui/QuickSettingsComponents.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/quicksettings/ui/QuickSettingsComponents.kt index 0f67ac3777..d27fd928d6 100644 --- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/quicksettings/ui/QuickSettingsComponents.kt +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/quicksettings/ui/QuickSettingsComponents.kt @@ -85,6 +85,7 @@ import com.google.jetpackcamera.ui.components.capture.ROW_QUICK_SETTINGS_CAPTURE import com.google.jetpackcamera.ui.components.capture.ROW_QUICK_SETTINGS_FLASH import com.google.jetpackcamera.ui.components.capture.ROW_QUICK_SETTINGS_HDR import com.google.jetpackcamera.ui.components.capture.SETTINGS_BUTTON +import com.google.jetpackcamera.ui.components.capture.animateToUpright import com.google.jetpackcamera.ui.components.capture.containerColor import com.google.jetpackcamera.ui.components.capture.layout.CameraLayoutDefaults import com.google.jetpackcamera.ui.components.capture.layout.LocalCameraLayoutSolution @@ -138,6 +139,7 @@ fun ToggleQuickSettingsButton( IconButton( modifier = modifier .size(buttonSize) + .animateToUpright() .testTag(QUICK_SETTINGS_DROP_DOWN) .semantics { testTag = QUICK_SETTINGS_DROP_DOWN @@ -555,7 +557,9 @@ fun HdrIndicator(hdrUiState: HdrUiState, modifier: Modifier = Modifier) { modifier = modifier ) { Icon( - modifier = Modifier.size(IconButtonDefaults.smallIconSize), + modifier = Modifier + .size(IconButtonDefaults.smallIconSize) + .animateToUpright(), painter = CameraDynamicRange.HDR.getPainter(), contentDescription = stringResource(CameraDynamicRange.HDR.getDescriptionResId()) ) @@ -600,7 +604,9 @@ private fun TopBarQuickSettingIcon(enum: QuickSettingsEnum, modifier: Modifier = Icon( painter = enum.getPainter(), contentDescription = stringResource(id = enum.getDescriptionResId()), - modifier = modifier.size(IconButtonDefaults.smallIconSize) + modifier = modifier + .size(IconButtonDefaults.smallIconSize) + .animateToUpright() ) } diff --git a/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/ContentGravityTest.kt b/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/ContentGravityTest.kt new file mode 100644 index 0000000000..df1c665275 --- /dev/null +++ b/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/ContentGravityTest.kt @@ -0,0 +1,784 @@ +/* + * 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 android.net.Uri +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SheetValue +import androidx.compose.material3.SnackbarDuration +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.getBoundsInRoot +import androidx.compose.ui.test.getUnclippedBoundsInRoot +import androidx.compose.ui.test.hasAnyAncestor +import androidx.compose.ui.test.isDialog +import androidx.compose.ui.test.isPopup +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.DpRect +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.height +import androidx.compose.ui.unit.width +import androidx.core.graphics.createBitmap +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage +import com.google.jetpackcamera.core.camera.AudioStreamState +import com.google.jetpackcamera.core.camera.VideoRecordingState +import com.google.jetpackcamera.model.AspectRatio +import com.google.jetpackcamera.model.CaptureMode +import com.google.jetpackcamera.model.DEFAULT_HDR_DYNAMIC_RANGE +import com.google.jetpackcamera.model.DEFAULT_HDR_IMAGE_OUTPUT +import com.google.jetpackcamera.model.DeviceRotation +import com.google.jetpackcamera.model.FlashMode +import com.google.jetpackcamera.model.LensFacing +import com.google.jetpackcamera.model.MediaDescriptor +import com.google.jetpackcamera.model.StabilizationMode +import com.google.jetpackcamera.model.VideoQuality +import com.google.jetpackcamera.ui.components.capture.layout.CameraLayoutMode +import com.google.jetpackcamera.ui.components.capture.layout.LocalCameraLayoutMode +import com.google.jetpackcamera.ui.components.capture.quicksettings.QuickSettingsScaffoldContent +import com.google.jetpackcamera.ui.components.capture.quicksettings.ui.FlashModeIndicator +import com.google.jetpackcamera.ui.components.capture.quicksettings.ui.HdrIndicator +import com.google.jetpackcamera.ui.components.capture.quicksettings.ui.ToggleQuickSettingsButton +import com.google.jetpackcamera.ui.controller.testing.FakeQuickSettingsController +import com.google.jetpackcamera.ui.uistate.CustomSnackbarVisuals +import com.google.jetpackcamera.ui.uistate.SingleSelectableUiState +import com.google.jetpackcamera.ui.uistate.capture.AspectRatioUiState +import com.google.jetpackcamera.ui.uistate.capture.AudioUiState +import com.google.jetpackcamera.ui.uistate.capture.CaptureModeUiState +import com.google.jetpackcamera.ui.uistate.capture.ElapsedTimeUiState +import com.google.jetpackcamera.ui.uistate.capture.FlashModeUiState +import com.google.jetpackcamera.ui.uistate.capture.FlipLensUiState +import com.google.jetpackcamera.ui.uistate.capture.HdrUiState +import com.google.jetpackcamera.ui.uistate.capture.ImageWellUiState +import com.google.jetpackcamera.ui.uistate.capture.StabilizationUiState +import com.google.jetpackcamera.ui.uistate.capture.compound.QuickSettingsUiState +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Robolectric Compose UI tests verifying content gravity rotation: + * - In-place glyph rotation (`Modifier.animateToUpright()`) on `1:1` controls and indicator icons. + * - Swapped-constraint rectangular block rotation (`Modifier.rotateLayoutToGravity()`) on + * in-hierarchy [CameraErrorDialog], landscape `PillSnackbarHost` + `ElapsedTimeText` top-edge + * banner, and compact [QuickSettingsScaffoldContent] bottom sheet. + * - Zero (`0°`) gravity rotation enforcement in [TwoSideRailLayout] and [WideShortLayout]. + */ +@OptIn(ExperimentalMaterial3Api::class) +@RunWith(RobolectricTestRunner::class) +@Config(qualifiers = "w1400dp-h1000dp-mdpi") +class ContentGravityTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private fun unclippedBoundsOf(tag: String): DpRect = + composeTestRule.onNodeWithTag(tag).getUnclippedBoundsInRoot() + + private fun clippedBoundsOf(tag: String): DpRect = + composeTestRule.onNodeWithTag(tag).getBoundsInRoot() + + private fun sampleQuickSettingsUiState(): QuickSettingsUiState.Available = + QuickSettingsUiState.Available( + aspectRatioUiState = AspectRatioUiState.Available( + selectedAspectRatio = AspectRatio.THREE_FOUR, + availableAspectRatios = listOf( + SingleSelectableUiState.SelectableUi(AspectRatio.THREE_FOUR), + SingleSelectableUiState.SelectableUi(AspectRatio.ONE_ONE), + SingleSelectableUiState.SelectableUi(AspectRatio.NINE_SIXTEEN) + ) + ), + captureModeUiState = CaptureModeUiState.Available( + selectedCaptureMode = CaptureMode.STANDARD, + availableCaptureModes = listOf( + SingleSelectableUiState.SelectableUi(CaptureMode.STANDARD), + SingleSelectableUiState.SelectableUi(CaptureMode.VIDEO_ONLY), + SingleSelectableUiState.SelectableUi(CaptureMode.IMAGE_ONLY) + ) + ), + flashModeUiState = FlashModeUiState.Available( + selectedFlashMode = FlashMode.OFF, + availableFlashModes = listOf( + SingleSelectableUiState.SelectableUi(FlashMode.OFF), + SingleSelectableUiState.SelectableUi(FlashMode.ON), + SingleSelectableUiState.SelectableUi(FlashMode.AUTO) + ), + isLowLightBoostActive = false + ), + flipLensUiState = FlipLensUiState.Available( + selectedLensFacing = LensFacing.BACK, + availableLensFacings = listOf( + SingleSelectableUiState.SelectableUi(LensFacing.BACK), + SingleSelectableUiState.SelectableUi(LensFacing.FRONT) + ) + ), + hdrUiState = HdrUiState.Available( + selectedDynamicRange = DEFAULT_HDR_DYNAMIC_RANGE, + selectedImageFormat = DEFAULT_HDR_IMAGE_OUTPUT, + isSupported = true + ) + ) + + @Test + fun squareControls_inCompactPortraitLandscape_preserveBoundsRotatingInPlace() { + val gravityState = mutableFloatStateOf(0f) + val measuredSlotSizes = mutableMapOf() + val thumbnailBitmap = createBitmap(64, 64) + val pauseSlotTag = "slot_pause_resume" + + composeTestRule.setContent { + MaterialTheme { + CompositionLocalProvider( + LocalDensity provides Density(1f), + LocalDisableAnimations provides true, + LocalGravityRotationState provides gravityState + ) { + Column { + Row { + Box(Modifier.onSizeChanged { measuredSlotSizes["flip"] = it }) { + FlipCameraButton( + modifier = Modifier.testTag(FLIP_CAMERA_BUTTON), + flipLensUiState = FlipLensUiState.Available( + selectedLensFacing = LensFacing.BACK, + availableLensFacings = listOf( + SingleSelectableUiState.SelectableUi(LensFacing.BACK), + SingleSelectableUiState.SelectableUi(LensFacing.FRONT) + ) + ), + onClick = {} + ) + } + Box( + Modifier.onSizeChanged { measuredSlotSizes["quickSettings"] = it } + ) { + ToggleQuickSettingsButton( + isOpen = false, + onClick = {} + ) + } + Box(Modifier.onSizeChanged { measuredSlotSizes["imageWell"] = it }) { + ImageWell( + imageWellUiState = ImageWellUiState.Content( + mediaDescriptor = MediaDescriptor.Content.Image( + uri = Uri.EMPTY, + thumbnail = thumbnailBitmap + ) + ), + onClick = {} + ) + } + Box(Modifier.onSizeChanged { measuredSlotSizes["pause"] = it }) { + PauseResumeToggleButton( + modifier = Modifier.testTag(pauseSlotTag), + onSetPause = {}, + currentRecordingStateProvider = { + VideoRecordingState.Active.Recording( + maxDurationMillis = 0L, + audioStreamState = AudioStreamState.Disabled, + elapsedTimeNanos = 1_000_000_000L + ) + } + ) + } + Box(Modifier.onSizeChanged { measuredSlotSizes["audio"] = it }) { + AmplitudeToggleButton( + audioUiState = AudioUiState.Enabled.On( + amplitude = 0.5, + isAudioStreamActive = true + ), + onToggleAudio = {} + ) + } + } + Box(Modifier.onSizeChanged { measuredSlotSizes["indicatorPill"] = it }) { + IndicatorPill { + HdrIndicator( + hdrUiState = HdrUiState.Available( + selectedDynamicRange = DEFAULT_HDR_DYNAMIC_RANGE, + selectedImageFormat = DEFAULT_HDR_IMAGE_OUTPUT, + isSupported = true + ) + ) + FlashModeIndicator( + flashModeUiState = FlashModeUiState.Available( + selectedFlashMode = FlashMode.ON, + availableFlashModes = listOf( + SingleSelectableUiState.SelectableUi(FlashMode.OFF), + SingleSelectableUiState.SelectableUi(FlashMode.ON) + ), + isLowLightBoostActive = false + ) + ) + StabilizationIcon( + stabilizationUiState = StabilizationUiState.Specific( + stabilizationMode = StabilizationMode.ON, + active = true + ) + ) + VideoQualityIcon( + videoQuality = VideoQuality.FHD, + modifier = Modifier.testTag(VIDEO_QUALITY_TAG) + ) + } + } + } + } + } + } + composeTestRule.waitForIdle() + + val baselineMeasuredSizes = measuredSlotSizes.toMap() + val baselineQuickSettingsSize = + composeTestRule.onNodeWithTag(QUICK_SETTINGS_DROP_DOWN).fetchSemanticsNode().size + val baselineQuickSettingsBounds = clippedBoundsOf(QUICK_SETTINGS_DROP_DOWN) + val squareTags = listOf( + FLIP_CAMERA_BUTTON, + IMAGE_WELL_TAG, + pauseSlotTag, + AUDIO_INPUT_TOGGLE + ) + val baselineSquareBounds = squareTags.associateWith { clippedBoundsOf(it) } + val baselineVideoQualityBounds = clippedBoundsOf(VIDEO_QUALITY_TAG) + + squareTags.forEach { tag -> + val bounds = baselineSquareBounds.getValue(tag) + assertWithMessage("$tag should be square at 0f") + .that(bounds.width.value) + .isWithin(TOLERANCE) + .of(bounds.height.value) + } + + for (angle in listOf(90f, -90f)) { + gravityState.floatValue = angle + composeTestRule.waitForIdle() + + // Layout pass sizes of every slot remain 100% invariant (zero sibling shift). + assertThat(measuredSlotSizes).isEqualTo(baselineMeasuredSizes) + assertThat( + composeTestRule.onNodeWithTag(QUICK_SETTINGS_DROP_DOWN).fetchSemanticsNode().size + ).isEqualTo(baselineQuickSettingsSize) + + // 1:1 square controls preserve identical square bounds in root across ±90f. + squareTags.forEach { tag -> + val rotatedBounds = clippedBoundsOf(tag) + val initialBounds = baselineSquareBounds.getValue(tag) + assertWithMessage("$tag width at $angle") + .that(rotatedBounds.width.value) + .isWithin(TOLERANCE) + .of(initialBounds.width.value) + assertWithMessage("$tag height at $angle") + .that(rotatedBounds.height.value) + .isWithin(TOLERANCE) + .of(initialBounds.height.value) + } + + // ToggleQuickSettingsButton (48x56dp narrow container) keeps its 48x56dp layout slot + // while its layer rotates in-place to 56x48dp in root at ±90f. + val rotatedQsBounds = clippedBoundsOf(QUICK_SETTINGS_DROP_DOWN) + assertThat(rotatedQsBounds.width.value) + .isWithin(TOLERANCE) + .of(baselineQuickSettingsBounds.height.value) + assertThat(rotatedQsBounds.height.value) + .isWithin(TOLERANCE) + .of(baselineQuickSettingsBounds.width.value) + + // IndicatorPill segment bounds remain completely unchanged while inner glyphs rotate. + val rotatedVqBounds = clippedBoundsOf(VIDEO_QUALITY_TAG) + assertThat(rotatedVqBounds.width.value) + .isWithin(TOLERANCE) + .of(baselineVideoQualityBounds.width.value) + assertThat(rotatedVqBounds.height.value) + .isWithin(TOLERANCE) + .of(baselineVideoQualityBounds.height.value) + } + } + + @Test + fun rectangularBlocks_inCompactPortraitLandscape_swapConstraintsWithoutClipping() { + // CameraErrorDialog in-hierarchy Surface inside a 360x740dp portrait container. + val dialogGravity = mutableFloatStateOf(0f) + var confirmClicked = false + + composeTestRule.setContent { + MaterialTheme { + CompositionLocalProvider( + LocalDensity provides Density(1f), + LocalDisableAnimations provides true, + LocalGravityRotationState provides dialogGravity + ) { + Box(modifier = Modifier.size(width = 360.dp, height = 740.dp)) { + CameraErrorDialog( + title = "Camera unavailable", + body = "Close other camera apps and try again.", + confirmButtonText = "OK", + onConfirm = { confirmClicked = true } + ) + } + } + } + } + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithTag(CAMERA_ERROR_DIALOG_TAG) + .assertIsDisplayed() + .assert(!isDialog()) + .assert(!isPopup()) + .assert(!hasAnyAncestor(isDialog())) + .assert(!hasAnyAncestor(isPopup())) + + // At 90°, Surface swaps minWidth = 280.dp into vertical portrait height >= 280.dp while + // fitting cleanly within the 360.dp width without clipping. + dialogGravity.floatValue = 90f + composeTestRule.waitForIdle() + + val titleLocalSize = + composeTestRule.onNodeWithTag(CAMERA_ERROR_DIALOG_TITLE_TAG).fetchSemanticsNode().size + val titleClipped = clippedBoundsOf(CAMERA_ERROR_DIALOG_TITLE_TAG) + val bodyLocalSize = + composeTestRule.onNodeWithTag(CAMERA_ERROR_DIALOG_BODY_TAG).fetchSemanticsNode().size + val bodyClipped = clippedBoundsOf(CAMERA_ERROR_DIALOG_BODY_TAG) + val buttonLocalSize = + composeTestRule + .onNodeWithTag(CAMERA_ERROR_DIALOG_CONFIRM_BUTTON_TAG) + .fetchSemanticsNode() + .size + val buttonClipped = clippedBoundsOf(CAMERA_ERROR_DIALOG_CONFIRM_BUTTON_TAG) + + // Rotated 90° CW: clipped root width equals unrotated local height and clipped root height + // equals unrotated local width (proving zero clipping after constraint swap). + assertThat(titleClipped.width.value).isWithin(TOLERANCE).of(titleLocalSize.height.toFloat()) + assertThat(titleClipped.height.value).isWithin(TOLERANCE).of(titleLocalSize.width.toFloat()) + assertThat(bodyClipped.width.value).isWithin(TOLERANCE).of(bodyLocalSize.height.toFloat()) + assertThat(bodyClipped.height.value).isWithin(TOLERANCE).of(bodyLocalSize.width.toFloat()) + assertThat(buttonClipped.width.value) + .isWithin(TOLERANCE) + .of(buttonLocalSize.height.toFloat()) + assertThat(buttonClipped.height.value) + .isWithin(TOLERANCE) + .of(buttonLocalSize.width.toFloat()) + + // Body spans >= 232.dp vertically (280.dp min card width - 48.dp card content padding), + // and confirm button hit target works cleanly in rotated coordinates. + assertThat(bodyClipped.height.value).isAtLeast(232f - TOLERANCE) + assertThat(titleClipped.left.value).isAtLeast(0f) + assertThat(titleClipped.right.value).isAtMost(360f) + composeTestRule.onNodeWithTag(CAMERA_ERROR_DIALOG_CONFIRM_BUTTON_TAG).performClick() + assertThat(confirmClicked).isTrue() + } + + @Test + fun landscapeTopEdgeBanner_inCompactPortrait_anchorsToTopEdgeInLtrAndRtl() { + var activeRotation by mutableStateOf(DeviceRotation.Rotated270) + var layoutDirection by mutableStateOf(LayoutDirection.Ltr) + + composeTestRule.setContent { + val scaffoldState = rememberCompactPortraitScaffoldState() + LaunchedEffect(scaffoldState.snackbarHostState) { + scaffoldState.snackbarHostState.showSnackbar( + CustomSnackbarVisuals( + message = "Storage nearly full", + actionLabel = null, + withDismissAction = false, + duration = SnackbarDuration.Indefinite, + isError = false + ) + ) + } + MaterialTheme { + CompositionLocalProvider( + LocalDensity provides Density(1f), + LocalDisableAnimations provides true, + LocalLayoutDirection provides layoutDirection + ) { + Box(modifier = Modifier.size(width = 740.dp, height = 360.dp)) { + CompactPortraitLayout( + scaffoldState = scaffoldState, + deviceRotation = activeRotation, + windowRotation = activeRotation, + viewfinder = { mod -> Box(mod) }, + captureButton = { mod -> Box(mod.size(80.dp)) }, + imageWell = { mod -> Box(mod.size(48.dp)) }, + flipCameraButton = { mod -> Box(mod.size(48.dp)) }, + zoomLevelDisplay = { mod -> + Box( + mod.size(width = 160.dp, height = 48.dp) + ) + }, + elapsedTimeDisplay = { mod -> + ElapsedTimeText( + modifier = mod, + elapsedTimeUiStateProvider = { + ElapsedTimeUiState.Enabled( + elapsedTimeNanos = 15_000_000_000L + ) + } + ) + }, + quickSettingsButton = { mod -> Box(mod.size(48.dp)) }, + indicatorRow = { mod -> Box(mod.size(width = 120.dp, height = 36.dp)) }, + captureModeToggle = { mod -> + Box( + mod.size(width = 96.dp, height = 48.dp) + ) + }, + quickSettingsOverlay = {} + ) + } + } + } + } + + for (dir in listOf(LayoutDirection.Ltr, LayoutDirection.Rtl)) { + for (rotation in listOf(DeviceRotation.Rotated270, DeviceRotation.Rotated90)) { + layoutDirection = dir + activeRotation = rotation + composeTestRule.waitForIdle() + + val snackbarClipped = clippedBoundsOf(SNACKBAR_NODE_TAG) + val snackbarUnclipped = unclippedBoundsOf(SNACKBAR_NODE_TAG) + val elapsedClipped = clippedBoundsOf(ELAPSED_TIME_TAG) + val elapsedUnclipped = unclippedBoundsOf(ELAPSED_TIME_TAG) + + // Banner sits at the physical top edge (y near 0.dp) of the 740x360dp landscape + // window with PillSnackbarHost above ElapsedTimeText and zero clipping. + assertWithMessage("Snackbar top at $rotation / $dir") + .that(snackbarUnclipped.top.value) + .isWithin(TOLERANCE) + .of(0f) + assertWithMessage("Snackbar horizontal orientation at $rotation / $dir") + .that(snackbarUnclipped.width.value) + .isGreaterThan(snackbarUnclipped.height.value) + assertWithMessage("ElapsedTime horizontal orientation at $rotation / $dir") + .that(elapsedUnclipped.width.value) + .isGreaterThan(elapsedUnclipped.height.value) + assertWithMessage("Snackbar sits above ElapsedTime at $rotation / $dir") + .that(snackbarUnclipped.bottom.value) + .isAtMost(elapsedUnclipped.top.value + TOLERANCE) + assertThat(snackbarClipped.width.value) + .isWithin(TOLERANCE) + .of(snackbarUnclipped.width.value) + assertThat(elapsedClipped.width.value) + .isWithin(TOLERANCE) + .of(elapsedUnclipped.width.value) + } + } + } + + @Test + fun quickSettingsSheetContent_inLandscapeGravity_swapsConstraintsViaRotateLayoutToGravity() { + var deviceRotation by mutableStateOf(DeviceRotation.Natural) + val fakeController = FakeQuickSettingsController() + var selectedFlash: FlashMode? = null + fakeController.setFlashAction = { selectedFlash = it } + + composeTestRule.setContent { + val scaffoldState = rememberCompactPortraitScaffoldState( + initialValue = SheetValue.Expanded + ) + MaterialTheme { + CompositionLocalProvider( + LocalDensity provides Density(1f), + LocalDisableAnimations provides true + ) { + Box(modifier = Modifier.size(width = 412.dp, height = 915.dp)) { + CompactPortraitLayout( + scaffoldState = scaffoldState, + deviceRotation = deviceRotation, + windowRotation = DeviceRotation.Natural, + viewfinder = { mod -> Box(mod) }, + captureButton = { mod -> Box(mod.size(80.dp)) }, + imageWell = { mod -> Box(mod.size(48.dp)) }, + flipCameraButton = { mod -> Box(mod.size(48.dp)) }, + zoomLevelDisplay = { mod -> + Box( + mod.size(width = 160.dp, height = 48.dp) + ) + }, + elapsedTimeDisplay = {}, + quickSettingsButton = { mod -> Box(mod.size(48.dp)) }, + indicatorRow = { mod -> Box(mod.size(width = 120.dp, height = 36.dp)) }, + captureModeToggle = { mod -> + Box( + mod.size(width = 96.dp, height = 48.dp) + ) + }, + quickSettingsOverlay = { mod -> + QuickSettingsScaffoldContent( + modifier = mod, + quickSettingsUiState = sampleQuickSettingsUiState(), + onNavigateToSettings = {}, + quickSettingsController = fakeController + ) + } + ) + } + } + } + } + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithTag(QUICK_SETTINGS_BOTTOM_SHEET) + .assertIsDisplayed() + .assert(!isDialog()) + .assert(!isPopup()) + val portraitSheetBounds = clippedBoundsOf(QUICK_SETTINGS_BOTTOM_SHEET) + assertThat(portraitSheetBounds.width.value).isWithin(TOLERANCE).of(412f) + + // Rotate device to landscape (Rotated270 -> +90° gravity): sheet swaps constraints so its + // local width is capped at 380.dp and local height matches 412.dp, while rotated bounds in + // root span 412.dp wide by <= 380.dp tall. + deviceRotation = DeviceRotation.Rotated270 + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithTag(QUICK_SETTINGS_BOTTOM_SHEET).assertIsDisplayed() + val landscapeSheetLocalSize = + composeTestRule.onNodeWithTag(QUICK_SETTINGS_BOTTOM_SHEET).fetchSemanticsNode().size + val landscapeSheetBounds = clippedBoundsOf(QUICK_SETTINGS_BOTTOM_SHEET) + assertThat(landscapeSheetLocalSize.width).isAtMost(380) + assertThat(landscapeSheetLocalSize.height).isEqualTo(412) + assertThat(landscapeSheetBounds.width.value).isWithin(TOLERANCE).of(412f) + assertThat(landscapeSheetBounds.height.value).isAtMost(380f + TOLERANCE) + + // Rotated hit targets inside QuickSettingsScaffoldContent remain interactive. + composeTestRule.onNodeWithTag(BTN_QUICK_SETTINGS_FLASH_OPTION_ON).performClick() + assertThat(selectedFlash).isEqualTo(FlashMode.ON) + } + + @Test + fun twoSideRailAndWideShortLayouts_inLandscape_keepZeroGravityRotation() { + var useWideShort by mutableStateOf(false) + var showErrorDialog by mutableStateOf(true) + var observedRailGravity: Float? = null + val fakeController = FakeQuickSettingsController() + + @Composable + fun buildRailSlots(): CaptureSlots = PlaceholderCaptureSlots.sideRail( + isCompactHeight = useWideShort, + includeElapsedTime = true + ).copy( + quickSettingsOverlay = { mod -> + QuickSettingsScaffoldContent( + modifier = mod, + quickSettingsUiState = sampleQuickSettingsUiState(), + onNavigateToSettings = {}, + quickSettingsController = fakeController + ) + }, + errorDialog = { mod -> + if (showErrorDialog) { + CameraErrorDialog( + modifier = mod, + title = "Camera error", + body = "Try again", + confirmButtonText = "OK", + onConfirm = {} + ) + } + } + ) + + composeTestRule.setContent { + val containerSize = if (useWideShort) { + DpSize(800.dp, 400.dp) + } else { + DpSize(1280.dp, 800.dp) + } + val ambientGravity = remember { mutableFloatStateOf(90f) } + MaterialTheme { + CompositionLocalProvider( + LocalDensity provides Density(1f), + LocalDisableAnimations provides true, + LocalDeviceRotation provides DeviceRotation.Rotated270, + LocalWindowRotation provides DeviceRotation.Rotated270, + LocalGravityRotationState provides ambientGravity + ) { + Box(modifier = Modifier.size(containerSize)) { + val slots = buildRailSlots() + if (useWideShort) { + WideShortLayout( + slots = slots, + viewfinder = { mod -> + observedRailGravity = LocalGravityRotationState.current.value + PlaceholderViewfinder(modifier = mod) + }, + isQuickSettingsOpen = !showErrorDialog, + isErrorDialogShowing = showErrorDialog, + tagQuickSettingsCard = false + ) + } else { + TwoSideRailLayout( + slots = slots, + viewfinder = { mod -> + observedRailGravity = LocalGravityRotationState.current.value + PlaceholderViewfinder(modifier = mod) + }, + isQuickSettingsOpen = !showErrorDialog, + isErrorDialogShowing = showErrorDialog, + tagQuickSettingsCard = false + ) + } + } + } + } + } + + for (wideShort in listOf(false, true)) { + useWideShort = wideShort + showErrorDialog = true + composeTestRule.waitForIdle() + + // Even with ambient 90f gravity and Rotated270 device/window rotations, rail layouts + // enforce 0f gravity rotation. + assertThat(observedRailGravity).isEqualTo(0f) + + val titleBounds = clippedBoundsOf(CAMERA_ERROR_DIALOG_TITLE_TAG) + val bodyBounds = clippedBoundsOf(CAMERA_ERROR_DIALOG_BODY_TAG) + val confirmBounds = clippedBoundsOf(CAMERA_ERROR_DIALOG_CONFIRM_BUTTON_TAG) + assertThat(titleBounds.top.value).isLessThan(confirmBounds.top.value) + assertThat(bodyBounds.width.value).isGreaterThan(bodyBounds.height.value) + + // Open Quick Settings floating card and verify it also remains unrotated (0°). + showErrorDialog = false + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithTag(QUICK_SETTINGS_BOTTOM_SHEET).assertIsDisplayed() + val flashRowBounds = clippedBoundsOf(ROW_QUICK_SETTINGS_FLASH) + val captureModeRowBounds = clippedBoundsOf(ROW_QUICK_SETTINGS_CAPTURE_MODE) + assertThat(flashRowBounds.top.value).isLessThan(captureModeRowBounds.top.value) + assertThat(flashRowBounds.width.value).isGreaterThan(flashRowBounds.height.value) + composeTestRule.onNodeWithTag(SETTINGS_BUTTON).performScrollTo().assertIsDisplayed() + } + } + + @Config(qualifiers = "sw800dp-w1400dp-h1000dp-mdpi") + @Test + fun compactHorizontalSplitScreen_rotatesLayout90DegreesAndKeepsOverlaysUpright() { + var observedMode: CameraLayoutMode? = null + var observedInteriorGravity: Float? = null + + composeTestRule.setContent { + MaterialTheme { + CompositionLocalProvider( + LocalDensity provides Density(1f), + LocalDisableAnimations provides true + ) { + PreviewLayout( + modifier = Modifier.size(width = 800.dp, height = 400.dp), + deviceRotation = DeviceRotation.Natural, + windowRotation = DeviceRotation.Natural, + viewfinder = { mod -> + observedMode = LocalCameraLayoutMode.current + observedInteriorGravity = LocalGravityRotationState.current.value + PlaceholderViewfinder(modifier = mod) + }, + captureButton = { mod -> + Box(mod.size(80.dp).testTag(CAPTURE_BUTTON)) + }, + imageWell = { mod -> + Box(mod.size(48.dp).testTag(IMAGE_WELL_TAG)) + }, + flipCameraButton = { mod -> + Box(mod.size(48.dp).testTag(FLIP_CAMERA_BUTTON)) + }, + zoomLevelDisplay = { mod -> + Box( + mod.size( + width = 160.dp, + height = 48.dp + ).testTag(ZOOM_BUTTON_ROW_TAG) + ) + }, + elapsedTimeDisplay = { mod -> + ElapsedTimeText( + modifier = mod, + elapsedTimeUiStateProvider = { + ElapsedTimeUiState.Enabled(elapsedTimeNanos = 0L) + } + ) + }, + quickSettingsButton = { mod -> + Box(mod.size(48.dp).testTag(QUICK_SETTINGS_DROP_DOWN)) + }, + indicatorRow = {}, + captureModeToggle = { mod -> + Box(mod.size(width = 96.dp, height = 48.dp)) + }, + quickSettingsOverlay = {}, + debugOverlay = {}, + debugVisibilityWrapper = { content -> content() }, + screenFlashOverlay = {}, + snackBar = { _, _ -> }, + errorDialog = { mod -> + CameraErrorDialog( + modifier = mod, + title = "Camera error", + body = "Try again", + confirmButtonText = "OK", + onConfirm = {} + ) + } + ) + } + } + } + composeTestRule.waitForIdle() + + assertThat(observedMode).isEqualTo(CameraLayoutMode.COMPACT_HORIZONTAL) + assertThat(((observedInteriorGravity!! % 360f) + 360f) % 360f).isEqualTo(90f) + + // Inside the -90° CCW rotated CompactPortraitLayout, +90° CW interior gravity brings + // rectangular overlays back to 0° upright in the 800x400dp screen window. + val elapsedBounds = clippedBoundsOf(ELAPSED_TIME_TAG) + assertThat(elapsedBounds.width.value).isGreaterThan(elapsedBounds.height.value) + + val titleBounds = clippedBoundsOf(CAMERA_ERROR_DIALOG_TITLE_TAG) + val bodyBounds = clippedBoundsOf(CAMERA_ERROR_DIALOG_BODY_TAG) + val confirmBounds = clippedBoundsOf(CAMERA_ERROR_DIALOG_CONFIRM_BUTTON_TAG) + assertThat(titleBounds.top.value).isLessThan(confirmBounds.top.value) + assertThat(bodyBounds.width.value).isGreaterThan(bodyBounds.height.value) + } + + private companion object { + const val TOLERANCE = 0.5f + } +} From 152dcb4498e9af6e9d5d64b85ba028be2b11c233 Mon Sep 17 00:00:00 2001 From: Kimberly Crevecoeur Date: Fri, 6 Nov 2026 07:52:21 -0800 Subject: [PATCH 2/5] feat(ui/capture): add content gravity rotation for 1:1 glyphs and rectangular overlays --- .../components/capture/CameraErrorDialog.kt | 28 +- .../capture/CaptureButtonComponents.kt | 5 +- .../ui/components/capture/CaptureLayout.kt | 14 +- .../capture/CaptureScreenComponents.kt | 14 +- .../capture/CompactPortraitLayout.kt | 204 ++++- .../ui/components/capture/ImageWell.kt | 4 +- .../components/capture/RotationModifiers.kt | 103 ++- .../components/capture/TwoSideRailLayout.kt | 5 +- .../ui/components/capture/WideShortLayout.kt | 95 ++- .../quicksettings/QuickSettingsScreen.kt | 39 +- .../ui/QuickSettingsComponents.kt | 10 +- .../components/capture/ContentGravityTest.kt | 784 ++++++++++++++++++ 12 files changed, 1197 insertions(+), 108 deletions(-) create mode 100644 ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/ContentGravityTest.kt diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CameraErrorDialog.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CameraErrorDialog.kt index eeedec2c5d..dfb9bd8a15 100644 --- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CameraErrorDialog.kt +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CameraErrorDialog.kt @@ -45,6 +45,16 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +private object CameraErrorDialogTokens { + val CardHorizontalMargin = 24.dp + val CardMinWidth = 280.dp + val CardMaxWidth = 560.dp + val ContentPadding = 24.dp + val IconSize = 24.dp + val SectionSpacing = 16.dp + val ButtonTopSpacing = 24.dp +} + /** * In-hierarchy dialog for camera errors. Callers provide the copy. * @@ -85,8 +95,12 @@ fun CameraErrorDialog( ) { Surface( modifier = Modifier - .padding(horizontal = 24.dp) - .widthIn(min = 280.dp, max = 560.dp) + .rotateLayoutToGravity() + .padding(horizontal = CameraErrorDialogTokens.CardHorizontalMargin) + .widthIn( + min = CameraErrorDialogTokens.CardMinWidth, + max = CameraErrorDialogTokens.CardMaxWidth + ) .pointerInput(Unit) { detectTapGestures { /* Consume taps on the dialog card */ } }, @@ -95,16 +109,16 @@ fun CameraErrorDialog( tonalElevation = AlertDialogDefaults.TonalElevation ) { Column( - modifier = Modifier.padding(24.dp), + modifier = Modifier.padding(CameraErrorDialogTokens.ContentPadding), horizontalAlignment = Alignment.CenterHorizontally ) { Icon( painter = painterResource(R.drawable.ic_videocam_outline), contentDescription = null, tint = MaterialTheme.colorScheme.secondary, - modifier = Modifier.size(24.dp) + modifier = Modifier.size(CameraErrorDialogTokens.IconSize) ) - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(CameraErrorDialogTokens.SectionSpacing)) Text( text = title, style = MaterialTheme.typography.headlineSmall, @@ -115,7 +129,7 @@ fun CameraErrorDialog( .testTag(CAMERA_ERROR_DIALOG_TITLE_TAG) ) body?.let { - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(CameraErrorDialogTokens.SectionSpacing)) Text( text = it, style = MaterialTheme.typography.bodyMedium, @@ -125,7 +139,7 @@ fun CameraErrorDialog( .testTag(CAMERA_ERROR_DIALOG_BODY_TAG) ) } - Spacer(modifier = Modifier.height(24.dp)) + Spacer(modifier = Modifier.height(CameraErrorDialogTokens.ButtonTopSpacing)) Box( modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd 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 e6c3179871..4831579fe2 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 @@ -813,7 +813,10 @@ private fun LockSwitchCaptureButtonNucleus( } ) { Icon( - modifier = Modifier.size(switchHeight * .75f).align(Alignment.Center), + modifier = Modifier + .size(switchHeight * .75f) + .align(Alignment.Center) + .animateToUpright(), tint = Color.White, painter = if (shouldBeLocked()) { painterResource(R.drawable.ic_lock) 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 3226eff11c..d5086cb66c 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 @@ -179,8 +179,20 @@ fun PreviewLayout( ) val isQuickSettingsOpen = scaffoldState.bottomSheetState.isVisible || scaffoldState.bottomSheetState.targetValue == SheetValue.Expanded + val isCompactPortraitBacked = layoutMode == CameraLayoutMode.COMPACT_PORTRAIT || + layoutMode == CameraLayoutMode.COMPACT_HORIZONTAL || + layoutMode == CameraLayoutMode.COMPACT_SLIM + val modeGravityRotationState = + if (isCompactPortraitBacked) { + gravityRotationState + } else { + DefaultGravityRotationState + } - CompositionLocalProvider(LocalCameraLayoutMode provides layoutMode) { + CompositionLocalProvider( + LocalCameraLayoutMode provides layoutMode, + LocalGravityRotationState provides modeGravityRotationState + ) { when (layoutMode) { CameraLayoutMode.COMPACT_PORTRAIT, CameraLayoutMode.COMPACT_HORIZONTAL, 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 93bc296dca..0cbcff4385 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 @@ -510,7 +510,9 @@ fun PauseResumeToggleButton( currentRecordingState !is VideoRecordingState.Active.Paused ) }, - modifier = modifier.size(size) + modifier = modifier + .size(size) + .animateToUpright() ) { Icon( modifier = Modifier @@ -566,6 +568,7 @@ fun AmplitudeToggleButton( FilledIconToggleButton( modifier = modifier .size(buttonSize) + .animateToUpright() .testTag(AUDIO_INPUT_TOGGLE) .semantics { stateDescription = if (audioUiState is AudioUiState.Enabled.On) { @@ -1240,7 +1243,9 @@ fun StabilizationIcon(stabilizationUiState: StabilizationUiState, modifier: Modi ) { if (displayedState != null) { Icon( - modifier = Modifier.size(IconButtonDefaults.smallIconSize), + modifier = Modifier + .size(IconButtonDefaults.smallIconSize) + .animateToUpright(), painter = when (displayedState) { is StabilizationUiState.Specific -> when (displayedState.stabilizationMode) { @@ -1325,7 +1330,9 @@ fun VideoQualityIcon(videoQuality: VideoQuality, modifier: Modifier = Modifier) ) { if (displayedQuality != null) { Icon( - modifier = Modifier.size(IconButtonDefaults.smallIconSize), + modifier = Modifier + .size(IconButtonDefaults.smallIconSize) + .animateToUpright(), painter = when (displayedQuality) { VideoQuality.SD -> painterResource(R.drawable.video_resolution_sd_icon) @@ -1519,6 +1526,7 @@ fun FlipCameraButton( contentDescription = contentDescription, modifier = Modifier .size(IconButtonDefaults.largeIconSize) + .animateToUpright() .rotate(animatedRotation.value) ) } diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CompactPortraitLayout.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CompactPortraitLayout.kt index 1a63f8c849..c33246568b 100644 --- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CompactPortraitLayout.kt +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CompactPortraitLayout.kt @@ -15,12 +15,17 @@ */ package com.google.jetpackcamera.ui.components.capture +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi @@ -34,6 +39,7 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.navigationBarsIgnoringVisibility import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding @@ -56,15 +62,20 @@ import androidx.compose.material3.rememberBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.AbsoluteAlignment import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.AlignmentLine import androidx.compose.ui.layout.Layout import androidx.compose.ui.layout.Placeable @@ -88,6 +99,7 @@ import com.google.jetpackcamera.ui.components.capture.layout.CameraLayoutSolver import com.google.jetpackcamera.ui.components.capture.layout.CameraRowIds import com.google.jetpackcamera.ui.components.capture.layout.CameraWindow import com.google.jetpackcamera.ui.components.capture.layout.LocalCameraLayoutSolution +import kotlinx.coroutines.delay private object CompactPortraitTokens { val SheetPeekHeight = 0.dp @@ -95,6 +107,21 @@ private object CompactPortraitTokens { val DefaultMinTouchTarget = 48.dp val ZeroInset = 0.dp + /** Maximum portrait-space height (landscape width) of the Quick Settings bottom sheet. */ + val LandscapeMaxSheetHeight = 380.dp + + /** Cross-fade duration when rotating the Quick Settings sheet while open. */ + const val QUICK_SETTINGS_FADE_DURATION_MS = 150 + + /** Sheet container resize duration when rotating the Quick Settings sheet while open. */ + const val QUICK_SETTINGS_RESIZE_DURATION_MS = 250 + + /** Clockwise gravity degrees corresponding to [DeviceRotation.Rotated270]. */ + const val LANDSCAPE_CCW_GRAVITY_DEGREES = 90 + + /** Clockwise gravity degrees corresponding to [DeviceRotation.Rotated90]. */ + const val LANDSCAPE_CW_GRAVITY_DEGREES = 270 + /** * Height of the shutter row (capture button, image well and flip camera button) as specified * by the design spec. @@ -465,6 +492,7 @@ fun CompactPortraitLayout( ) { val overlapTargetBounds = remember { mutableStateOf(Rect.Zero) } val localeDirection = LocalLayoutDirection.current + val disableAnimations = LocalDisableAnimations.current val dismissQuickSettingsLabel = stringResource(R.string.quick_settings_btn_close_expanded_settings_description) val isSheetVisible = scaffoldState.bottomSheetState.isVisible || @@ -508,6 +536,48 @@ fun CompactPortraitLayout( deviceRotation = effectiveDeviceRotation, windowRotation = containerFrameRotation ) + val targetGravityDegrees = + effectiveDeviceRotation.toGravityRotationDegrees(containerFrameRotation) + val isLandscapeGravity = + targetGravityDegrees == CompactPortraitTokens.LANDSCAPE_CCW_GRAVITY_DEGREES || + targetGravityDegrees == CompactPortraitTokens.LANDSCAPE_CW_GRAVITY_DEGREES + + var displayedSheetGravityDegrees by remember { mutableIntStateOf(targetGravityDegrees) } + val sheetContentAlpha = remember { Animatable(1f) } + if (displayedSheetGravityDegrees != targetGravityDegrees && + (!isSheetVisible || disableAnimations) + ) { + displayedSheetGravityDegrees = targetGravityDegrees + } + + LaunchedEffect(targetGravityDegrees, isSheetVisible, disableAnimations) { + if (displayedSheetGravityDegrees != targetGravityDegrees) { + if (isSheetVisible && !disableAnimations) { + sheetContentAlpha.animateTo( + targetValue = 0f, + animationSpec = tween( + durationMillis = CompactPortraitTokens.QUICK_SETTINGS_FADE_DURATION_MS, + easing = FastOutSlowInEasing + ) + ) + displayedSheetGravityDegrees = targetGravityDegrees + delay(CompactPortraitTokens.QUICK_SETTINGS_RESIZE_DURATION_MS.toLong()) + sheetContentAlpha.animateTo( + targetValue = 1f, + animationSpec = tween( + durationMillis = CompactPortraitTokens.QUICK_SETTINGS_FADE_DURATION_MS, + easing = FastOutSlowInEasing + ) + ) + } else { + displayedSheetGravityDegrees = targetGravityDegrees + sheetContentAlpha.snapTo(1f) + } + } else if (sheetContentAlpha.value < 1f && (!isSheetVisible || disableAnimations)) { + sheetContentAlpha.snapTo(1f) + } + } + val sheetGravityRotationState = rememberUpdatedState(displayedSheetGravityDegrees.toFloat()) CompositionLocalProvider( LocalOverlapTargetBounds provides overlapTargetBounds, @@ -541,8 +611,56 @@ fun CompactPortraitLayout( ), sheetContainerColor = MaterialTheme.colorScheme.surfaceContainerLow, sheetContent = { - CompositionLocalProvider(LocalLayoutDirection provides localeDirection) { - quickSettingsOverlay(Modifier) + val isDisplayedSheetLandscape = + displayedSheetGravityDegrees == + CompactPortraitTokens.LANDSCAPE_CCW_GRAVITY_DEGREES || + displayedSheetGravityDegrees == + CompactPortraitTokens.LANDSCAPE_CW_GRAVITY_DEGREES + CompositionLocalProvider( + LocalLayoutDirection provides localeDirection, + LocalGravityRotationState provides sheetGravityRotationState + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .then( + if (disableAnimations) { + Modifier + } else { + Modifier.animateContentSize( + animationSpec = tween( + durationMillis = + CompactPortraitTokens + .QUICK_SETTINGS_RESIZE_DURATION_MS, + easing = FastOutSlowInEasing + ) + ) + } + ) + .graphicsLayer { + alpha = sheetContentAlpha.value + } + ) { + if (isDisplayedSheetLandscape) { + Box( + modifier = Modifier + .fillMaxWidth() + .heightIn( + max = CompactPortraitTokens.LandscapeMaxSheetHeight + ) + .rotateLayoutToGravity(displayedSheetGravityDegrees), + contentAlignment = Alignment.TopStart + ) { + CompositionLocalProvider( + LocalLayoutDirection provides localeDirection + ) { + quickSettingsOverlay(Modifier) + } + } + } else { + quickSettingsOverlay(Modifier) + } + } } }, snackbarHost = {} @@ -556,6 +674,23 @@ fun CompactPortraitLayout( val isHandheldPortrait = windowAspectRatio >= CompactPortraitTokens.PORTRAIT_ASPECT_RATIO_THRESHOLD + val bottomElapsedTimeDisplay: @Composable (Modifier) -> Unit = + if (isLandscapeGravity) { + {} + } else { + elapsedTimeDisplay + } + val bottomSnackbarHost: @Composable (Modifier) -> Unit = + if (isLandscapeGravity) { + {} + } else { + { hostModifier -> + PillSnackbarHost( + snackbarHostState = scaffoldState.snackbarHostState, + modifier = hostModifier + ) + } + } if (isHandheldPortrait) { AdaptivePortraitCaptureLayout( windowWidth = portraitWidth, @@ -570,7 +705,7 @@ fun CompactPortraitLayout( onRequiresImmersiveChange = onRequiresImmersiveChange, indicatorRow = indicatorRow, topBarTrailingContent = topBarTrailingContent, - elapsedTimeDisplay = elapsedTimeDisplay, + elapsedTimeDisplay = bottomElapsedTimeDisplay, zoomControls = zoomLevelDisplay, imageWell = imageWell, captureButton = captureButton, @@ -579,12 +714,7 @@ fun CompactPortraitLayout( quickSettingsToggleButton = quickSettingsButton, captureModeToggleSwitch = captureModeToggle, debugVisibilityWrapper = debugVisibilityWrapper, - snackbarHost = { hostModifier -> - PillSnackbarHost( - snackbarHostState = scaffoldState.snackbarHostState, - modifier = hostModifier - ) - } + snackbarHost = bottomSnackbarHost ) Box( modifier = Modifier @@ -596,6 +726,15 @@ fun CompactPortraitLayout( .toPortraitWindowInsets(effectiveWindowRotation) ) ) { + if (isLandscapeGravity) { + LandscapeTopEdgeBanner( + targetGravityDegrees = targetGravityDegrees, + localeDirection = localeDirection, + snackbarHostState = scaffoldState.snackbarHostState, + elapsedTimeDisplay = elapsedTimeDisplay, + debugVisibilityWrapper = debugVisibilityWrapper + ) + } CompositionLocalProvider( LocalLayoutDirection provides localeDirection ) { @@ -629,6 +768,7 @@ fun CompactPortraitLayout( Modifier .align(Alignment.TopStart) .suppressBackgroundForModal(isSheetVisible) + .animateToUpright() ) { topStartContent(Modifier) } @@ -662,13 +802,17 @@ fun CompactPortraitLayout( captureModeToggleSwitch = captureModeToggle, captureModeCarousel = captureModeCarousel, zoomControls = zoomLevelDisplay, + elapsedTimeDisplay = bottomElapsedTimeDisplay, + snackbarHost = bottomSnackbarHost + ) + } + if (isLandscapeGravity) { + LandscapeTopEdgeBanner( + targetGravityDegrees = targetGravityDegrees, + localeDirection = localeDirection, + snackbarHostState = scaffoldState.snackbarHostState, elapsedTimeDisplay = elapsedTimeDisplay, - snackbarHost = { hostModifier -> - PillSnackbarHost( - snackbarHostState = scaffoldState.snackbarHostState, - modifier = hostModifier - ) - } + debugVisibilityWrapper = debugVisibilityWrapper ) } CompositionLocalProvider( @@ -710,6 +854,35 @@ fun CompactPortraitLayout( } } +@Composable +private fun BoxScope.LandscapeTopEdgeBanner( + targetGravityDegrees: Int, + localeDirection: LayoutDirection, + snackbarHostState: SnackbarHostState, + elapsedTimeDisplay: @Composable (Modifier) -> Unit, + debugVisibilityWrapper: @Composable (@Composable () -> Unit) -> Unit +) { + val landscapeTopAlignment = + if (targetGravityDegrees == CompactPortraitTokens.LANDSCAPE_CCW_GRAVITY_DEGREES) { + AbsoluteAlignment.CenterRight + } else { + AbsoluteAlignment.CenterLeft + } + debugVisibilityWrapper { + CompositionLocalProvider(LocalLayoutDirection provides localeDirection) { + Column( + modifier = Modifier + .align(landscapeTopAlignment) + .rotateLayoutToGravity(targetGravityDegrees), + horizontalAlignment = Alignment.CenterHorizontally + ) { + PillSnackbarHost(snackbarHostState = snackbarHostState) + elapsedTimeDisplay(Modifier) + } + } + } +} + @Composable private fun computeTopBarMetrics( includeStatusBarsInTopBar: Boolean, @@ -963,6 +1136,7 @@ internal fun AdaptivePortraitCaptureLayout( modifier = Modifier .layoutId(CaptureSlotId.TopStartContent) .suppressBackgroundForModal(isSheetVisible) + .animateToUpright() ) { OverlapAwareStyleProvider { topStartContent(Modifier) diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/ImageWell.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/ImageWell.kt index 2f1a48a6fc..16028ddbb5 100644 --- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/ImageWell.kt +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/ImageWell.kt @@ -135,7 +135,9 @@ fun ImageWell( val imageBitmap = remember(bitmap) { bitmap.asImageBitmap() } Image( bitmap = imageBitmap, - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize() + .animateToUpright(), contentDescription = stringResource( id = R.string.image_well_content_description ), diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/RotationModifiers.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/RotationModifiers.kt index 23262062c8..4d9c0c5af0 100644 --- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/RotationModifiers.kt +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/RotationModifiers.kt @@ -37,6 +37,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.ProvidableCompositionLocal import androidx.compose.runtime.State import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf @@ -55,9 +56,18 @@ import androidx.compose.ui.unit.LayoutDirection import androidx.core.content.ContextCompat import androidx.lifecycle.compose.LifecycleStartEffect import com.google.jetpackcamera.model.DeviceRotation +import kotlin.math.roundToInt private const val UI_ROTATION_DURATION_MS = 250 -private val DefaultGravityRotationState: State = mutableFloatStateOf(0f) +private const val QUADRANT_DEGREES = 90 +private const val FULL_CIRCLE_DEGREES = 360 + +/** + * Shared zero-degree (`0f`) gravity rotation [State] used as the default for + * [LocalGravityRotationState] and bound explicitly by adaptive side-rail layouts that stay + * naturally oriented with the OS window. + */ +internal val DefaultGravityRotationState: State = mutableFloatStateOf(0f) /** * Provides the current physical gravity [DeviceRotation] to descendant composables in the capture @@ -186,6 +196,34 @@ fun Modifier.animateToUpright(rotationState: State? = null): Modifier = c } } +/** + * Snaps an arbitrary continuous clockwise [degrees] angle to the nearest 90-degree quadrant + * (`0`, `90`, `180`, or `270`). + */ +internal fun snapToQuadrantDegrees(degrees: Float): Int { + if (!degrees.isFinite()) return 0 + val quadrants = (degrees / QUADRANT_DEGREES.toFloat()).roundToInt() + return ((quadrants * QUADRANT_DEGREES) % FULL_CIRCLE_DEGREES + FULL_CIRCLE_DEGREES) % + FULL_CIRCLE_DEGREES +} + +/** + * Measures and places a rectangular composable with swapped width and height constraints when + * [LocalGravityRotationState] snaps to `90°` or `270°`, centering the rotated layer within the + * swapped layout box so rectangular overlays orient upright without clipping or misaligned hit + * targets. + * + * When [LocalGravityRotationState] snaps to `0°` (such as in portrait or in adaptive side-rail + * layouts), constraints and placement remain unrotated. + */ +fun Modifier.rotateLayoutToGravity(): Modifier = composed { + val gravityState = LocalGravityRotationState.current + val snappedDegrees by remember(gravityState) { + derivedStateOf { snapToQuadrantDegrees(gravityState.value) } + } + rotateLayoutToGravity(snappedDegrees) +} + /** * Measures and places a rectangular composable with swapped width and height constraints when * [rotationDegrees] is `90°` or `270°`, centering the rotated layer within the swapped layout box @@ -193,36 +231,45 @@ fun Modifier.animateToUpright(rotationState: State? = null): Modifier = c */ fun Modifier.rotateLayoutToGravity(rotationDegrees: Int): Modifier = layout { measurable, constraints -> - val normalized = ((rotationDegrees % 360) + 360) % 360 - val swapAxes = normalized == 90 || normalized == 270 - val childConstraints = if (swapAxes) { - val swappedMaxWidth = if (constraints.hasBoundedHeight) { - constraints.maxHeight - } else { - constraints.maxWidth + val normalized = + ((rotationDegrees % FULL_CIRCLE_DEGREES) + FULL_CIRCLE_DEGREES) % FULL_CIRCLE_DEGREES + if (normalized == 0) { + val placeable = measurable.measure(constraints) + layout(placeable.width, placeable.height) { + placeable.place(0, 0) } - val swappedMaxHeight = if (constraints.hasBoundedWidth) { - constraints.maxWidth + } else { + val swapAxes = + normalized == QUADRANT_DEGREES || normalized == QUADRANT_DEGREES * 3 + val childConstraints = if (swapAxes) { + val swappedMaxWidth = if (constraints.hasBoundedHeight) { + constraints.maxHeight + } else { + constraints.maxWidth + } + val swappedMaxHeight = if (constraints.hasBoundedWidth) { + constraints.maxWidth + } else { + constraints.maxHeight + } + Constraints( + minWidth = constraints.minHeight.coerceAtMost(swappedMaxWidth), + maxWidth = swappedMaxWidth, + minHeight = constraints.minWidth.coerceAtMost(swappedMaxHeight), + maxHeight = swappedMaxHeight + ) } else { - constraints.maxHeight + constraints } - Constraints( - minWidth = constraints.minHeight.coerceAtMost(swappedMaxWidth), - maxWidth = swappedMaxWidth, - minHeight = constraints.minWidth.coerceAtMost(swappedMaxHeight), - maxHeight = swappedMaxHeight - ) - } else { - constraints - } - val placeable = measurable.measure(childConstraints) - val layoutWidth = if (swapAxes) placeable.height else placeable.width - val layoutHeight = if (swapAxes) placeable.width else placeable.height - layout(layoutWidth, layoutHeight) { - val offsetX = (layoutWidth - placeable.width) / 2 - val offsetY = (layoutHeight - placeable.height) / 2 - placeable.placeWithLayer(offsetX, offsetY) { - rotationZ = normalized.toFloat() + val placeable = measurable.measure(childConstraints) + val layoutWidth = if (swapAxes) placeable.height else placeable.width + val layoutHeight = if (swapAxes) placeable.width else placeable.height + layout(layoutWidth, layoutHeight) { + val offsetX = (layoutWidth - placeable.width) / 2 + val offsetY = (layoutHeight - placeable.height) / 2 + placeable.placeWithLayer(offsetX, offsetY) { + rotationZ = normalized.toFloat() + } } } } diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/TwoSideRailLayout.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/TwoSideRailLayout.kt index 067ff3737e..44b86a5377 100644 --- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/TwoSideRailLayout.kt +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/TwoSideRailLayout.kt @@ -177,7 +177,10 @@ internal fun SideRailCaptureScaffold( BackHandler(enabled = isQuickSettingsOpen, onBack = onDismissQuickSettings) - CompositionLocalProvider(LocalOverlapTargetBounds provides overlapTargetBounds) { + CompositionLocalProvider( + LocalOverlapTargetBounds provides overlapTargetBounds, + LocalGravityRotationState provides DefaultGravityRotationState + ) { Box( modifier = modifier .fillMaxSize() diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/WideShortLayout.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/WideShortLayout.kt index 02f8188556..cc3f01f37d 100644 --- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/WideShortLayout.kt +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/WideShortLayout.kt @@ -112,55 +112,60 @@ fun WideShortLayout( val localeDirection = LocalLayoutDirection.current val safePadding = WindowInsets.systemBars.union(WindowInsets.displayCutout).asPaddingValues() - BoxWithConstraints(modifier = modifier.fillMaxSize()) { - val horizontalInsets = safePadding.calculateLeftPadding(localeDirection) + - safePadding.calculateRightPadding(localeDirection) - val verticalInsets = safePadding.calculateTopPadding() + - safePadding.calculateBottomPadding() - val availableWidth = (maxWidth - horizontalInsets).coerceAtLeast(WideShortTokens.ZeroSize) - val availableHeight = (maxHeight - verticalInsets).coerceAtLeast(WideShortTokens.ZeroSize) + CompositionLocalProvider(LocalGravityRotationState provides DefaultGravityRotationState) { + BoxWithConstraints(modifier = modifier.fillMaxSize()) { + val horizontalInsets = safePadding.calculateLeftPadding(localeDirection) + + safePadding.calculateRightPadding(localeDirection) + val verticalInsets = safePadding.calculateTopPadding() + + safePadding.calculateBottomPadding() + val availableWidth = + (maxWidth - horizontalInsets).coerceAtLeast(WideShortTokens.ZeroSize) + val availableHeight = + (maxHeight - verticalInsets).coerceAtLeast(WideShortTokens.ZeroSize) - val railSlotMaxWidth = min( - CaptureSlotTokens.ModeToggleCompactWidth, - (availableWidth * WideShortTokens.RAIL_WIDTH_FRACTION) - .coerceAtLeast(WideShortTokens.MinRailSlotWidthFloor) - ) - val estimatedRailFootprint = railSlotMaxWidth + WideShortTokens.RailHorizontalPaddingTotal - val condensedSlots = remember(slots, availableWidth, availableHeight) { - slots.withCondensedSizeCaps( - availableWidth = availableWidth, - availableHeight = availableHeight, - railSlotMaxWidth = railSlotMaxWidth, - estimatedRailFootprint = estimatedRailFootprint + val railSlotMaxWidth = min( + CaptureSlotTokens.ModeToggleCompactWidth, + (availableWidth * WideShortTokens.RAIL_WIDTH_FRACTION) + .coerceAtLeast(WideShortTokens.MinRailSlotWidthFloor) ) - } - val cardMaxWidth = min( - WideShortTokens.QuickSettingsCardMaxWidth, - (availableWidth - WideShortTokens.CondensedChromePadding * 2) + val estimatedRailFootprint = + railSlotMaxWidth + WideShortTokens.RailHorizontalPaddingTotal + val condensedSlots = remember(slots, availableWidth, availableHeight) { + slots.withCondensedSizeCaps( + availableWidth = availableWidth, + availableHeight = availableHeight, + railSlotMaxWidth = railSlotMaxWidth, + estimatedRailFootprint = estimatedRailFootprint + ) + } + val cardMaxWidth = min( + WideShortTokens.QuickSettingsCardMaxWidth, + (availableWidth - WideShortTokens.CondensedChromePadding * 2) + .coerceAtLeast(WideShortTokens.ZeroSize) + ) + val cardMaxHeight = (availableHeight - WideShortTokens.CondensedChromePadding * 2) .coerceAtLeast(WideShortTokens.ZeroSize) - ) - val cardMaxHeight = (availableHeight - WideShortTokens.CondensedChromePadding * 2) - .coerceAtLeast(WideShortTokens.ZeroSize) - SideRailCaptureScaffold( - slots = condensedSlots, - viewfinder = viewfinder, - modifier = Modifier.fillMaxSize(), - snackbarHostState = snackbarHostState, - isCompactHeight = true, - chromePadding = WideShortTokens.CondensedChromePadding, - topOverlayHorizontalClearance = estimatedRailFootprint, - quickSettingsCardMaxWidth = cardMaxWidth, - quickSettingsCardMaxHeight = cardMaxHeight, - isQuickSettingsOpen = isQuickSettingsOpen, - isRecording = isRecording, - isErrorDialogShowing = isErrorDialogShowing, - tagQuickSettingsCard = tagQuickSettingsCard, - onDismissQuickSettings = onDismissQuickSettings, - debugOverlay = debugOverlay, - debugVisibilityWrapper = debugVisibilityWrapper, - screenFlashOverlay = screenFlashOverlay - ) + SideRailCaptureScaffold( + slots = condensedSlots, + viewfinder = viewfinder, + modifier = Modifier.fillMaxSize(), + snackbarHostState = snackbarHostState, + isCompactHeight = true, + chromePadding = WideShortTokens.CondensedChromePadding, + topOverlayHorizontalClearance = estimatedRailFootprint, + quickSettingsCardMaxWidth = cardMaxWidth, + quickSettingsCardMaxHeight = cardMaxHeight, + isQuickSettingsOpen = isQuickSettingsOpen, + isRecording = isRecording, + isErrorDialogShowing = isErrorDialogShowing, + tagQuickSettingsCard = tagQuickSettingsCard, + onDismissQuickSettings = onDismissQuickSettings, + debugOverlay = debugOverlay, + debugVisibilityWrapper = debugVisibilityWrapper, + screenFlashOverlay = screenFlashOverlay + ) + } } } diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/quicksettings/QuickSettingsScreen.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/quicksettings/QuickSettingsScreen.kt index 75447f5daa..90d275a5f7 100644 --- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/quicksettings/QuickSettingsScreen.kt +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/quicksettings/QuickSettingsScreen.kt @@ -22,6 +22,8 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBarsIgnoringVisibility import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -37,6 +39,7 @@ import com.google.jetpackcamera.model.DynamicRange import com.google.jetpackcamera.model.FlashMode import com.google.jetpackcamera.model.ImageOutputFormat import com.google.jetpackcamera.model.LensFacing +import com.google.jetpackcamera.ui.components.capture.LocalGravityRotationState import com.google.jetpackcamera.ui.components.capture.QUICK_SETTINGS_BOTTOM_SHEET import com.google.jetpackcamera.ui.components.capture.R import com.google.jetpackcamera.ui.components.capture.quicksettings.ui.AspectRatioRow @@ -44,6 +47,7 @@ import com.google.jetpackcamera.ui.components.capture.quicksettings.ui.CaptureMo import com.google.jetpackcamera.ui.components.capture.quicksettings.ui.FlashRow import com.google.jetpackcamera.ui.components.capture.quicksettings.ui.HdrRow import com.google.jetpackcamera.ui.components.capture.quicksettings.ui.QuickNavSettings +import com.google.jetpackcamera.ui.components.capture.snapToQuadrantDegrees import com.google.jetpackcamera.ui.controller.quicksettings.QuickSettingsController import com.google.jetpackcamera.ui.uistate.SingleSelectableUiState import com.google.jetpackcamera.ui.uistate.capture.AspectRatioUiState @@ -53,6 +57,17 @@ import com.google.jetpackcamera.ui.uistate.capture.FlipLensUiState import com.google.jetpackcamera.ui.uistate.capture.HdrUiState import com.google.jetpackcamera.ui.uistate.capture.compound.QuickSettingsUiState +private object QuickSettingsScreenTokens { + val ZeroPadding = 0.dp + val LandscapeTopPadding = 24.dp + val BottomPadding = 24.dp + val HeaderHorizontalPadding = 16.dp + val HeaderBottomPadding = 8.dp + val MoreSettingsTopPadding = 12.dp + const val LANDSCAPE_CCW_GRAVITY_DEGREES = 90 + const val LANDSCAPE_CW_GRAVITY_DEGREES = 270 +} + /** * Agnostic content for the Quick Settings panel wrapped for a BottomSheetScaffold sheet. * @@ -72,12 +87,24 @@ fun QuickSettingsScaffoldContent( showMoreSettingsButton: Boolean = true ) { if (quickSettingsUiState is QuickSettingsUiState.Available) { + val gravityDegrees = snapToQuadrantDegrees(LocalGravityRotationState.current.value) + val isLandscapeSheet = + gravityDegrees == QuickSettingsScreenTokens.LANDSCAPE_CCW_GRAVITY_DEGREES || + gravityDegrees == QuickSettingsScreenTokens.LANDSCAPE_CW_GRAVITY_DEGREES Column( modifier = modifier .fillMaxWidth() - .windowInsetsPadding(WindowInsets.navigationBarsIgnoringVisibility) - .padding(bottom = 24.dp) .testTag(QUICK_SETTINGS_BOTTOM_SHEET) + .verticalScroll(rememberScrollState()) + .windowInsetsPadding(WindowInsets.navigationBarsIgnoringVisibility) + .padding( + top = if (isLandscapeSheet) { + QuickSettingsScreenTokens.LandscapeTopPadding + } else { + QuickSettingsScreenTokens.ZeroPadding + }, + bottom = QuickSettingsScreenTokens.BottomPadding + ) ) { QuickSettingsContent( quickSettingsUiState = quickSettingsUiState, @@ -118,7 +145,11 @@ internal fun QuickSettingsContent( Column(modifier = modifier.fillMaxWidth()) { Text( - modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 8.dp), + modifier = Modifier.padding( + start = QuickSettingsScreenTokens.HeaderHorizontalPadding, + end = QuickSettingsScreenTokens.HeaderHorizontalPadding, + bottom = QuickSettingsScreenTokens.HeaderBottomPadding + ), text = stringResource(id = titleRes), style = MaterialTheme.typography.titleLarge ) @@ -171,7 +202,7 @@ internal fun QuickSettingsContent( if (showMoreSettingsButton) { QuickNavSettings( onNavigateToSettings = onNavigateToSettings, - modifier = Modifier.padding(top = 12.dp) + modifier = Modifier.padding(top = QuickSettingsScreenTokens.MoreSettingsTopPadding) ) } } diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/quicksettings/ui/QuickSettingsComponents.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/quicksettings/ui/QuickSettingsComponents.kt index 0f67ac3777..d27fd928d6 100644 --- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/quicksettings/ui/QuickSettingsComponents.kt +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/quicksettings/ui/QuickSettingsComponents.kt @@ -85,6 +85,7 @@ import com.google.jetpackcamera.ui.components.capture.ROW_QUICK_SETTINGS_CAPTURE import com.google.jetpackcamera.ui.components.capture.ROW_QUICK_SETTINGS_FLASH import com.google.jetpackcamera.ui.components.capture.ROW_QUICK_SETTINGS_HDR import com.google.jetpackcamera.ui.components.capture.SETTINGS_BUTTON +import com.google.jetpackcamera.ui.components.capture.animateToUpright import com.google.jetpackcamera.ui.components.capture.containerColor import com.google.jetpackcamera.ui.components.capture.layout.CameraLayoutDefaults import com.google.jetpackcamera.ui.components.capture.layout.LocalCameraLayoutSolution @@ -138,6 +139,7 @@ fun ToggleQuickSettingsButton( IconButton( modifier = modifier .size(buttonSize) + .animateToUpright() .testTag(QUICK_SETTINGS_DROP_DOWN) .semantics { testTag = QUICK_SETTINGS_DROP_DOWN @@ -555,7 +557,9 @@ fun HdrIndicator(hdrUiState: HdrUiState, modifier: Modifier = Modifier) { modifier = modifier ) { Icon( - modifier = Modifier.size(IconButtonDefaults.smallIconSize), + modifier = Modifier + .size(IconButtonDefaults.smallIconSize) + .animateToUpright(), painter = CameraDynamicRange.HDR.getPainter(), contentDescription = stringResource(CameraDynamicRange.HDR.getDescriptionResId()) ) @@ -600,7 +604,9 @@ private fun TopBarQuickSettingIcon(enum: QuickSettingsEnum, modifier: Modifier = Icon( painter = enum.getPainter(), contentDescription = stringResource(id = enum.getDescriptionResId()), - modifier = modifier.size(IconButtonDefaults.smallIconSize) + modifier = modifier + .size(IconButtonDefaults.smallIconSize) + .animateToUpright() ) } diff --git a/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/ContentGravityTest.kt b/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/ContentGravityTest.kt new file mode 100644 index 0000000000..df1c665275 --- /dev/null +++ b/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/ContentGravityTest.kt @@ -0,0 +1,784 @@ +/* + * 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 android.net.Uri +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SheetValue +import androidx.compose.material3.SnackbarDuration +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.getBoundsInRoot +import androidx.compose.ui.test.getUnclippedBoundsInRoot +import androidx.compose.ui.test.hasAnyAncestor +import androidx.compose.ui.test.isDialog +import androidx.compose.ui.test.isPopup +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.DpRect +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.height +import androidx.compose.ui.unit.width +import androidx.core.graphics.createBitmap +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage +import com.google.jetpackcamera.core.camera.AudioStreamState +import com.google.jetpackcamera.core.camera.VideoRecordingState +import com.google.jetpackcamera.model.AspectRatio +import com.google.jetpackcamera.model.CaptureMode +import com.google.jetpackcamera.model.DEFAULT_HDR_DYNAMIC_RANGE +import com.google.jetpackcamera.model.DEFAULT_HDR_IMAGE_OUTPUT +import com.google.jetpackcamera.model.DeviceRotation +import com.google.jetpackcamera.model.FlashMode +import com.google.jetpackcamera.model.LensFacing +import com.google.jetpackcamera.model.MediaDescriptor +import com.google.jetpackcamera.model.StabilizationMode +import com.google.jetpackcamera.model.VideoQuality +import com.google.jetpackcamera.ui.components.capture.layout.CameraLayoutMode +import com.google.jetpackcamera.ui.components.capture.layout.LocalCameraLayoutMode +import com.google.jetpackcamera.ui.components.capture.quicksettings.QuickSettingsScaffoldContent +import com.google.jetpackcamera.ui.components.capture.quicksettings.ui.FlashModeIndicator +import com.google.jetpackcamera.ui.components.capture.quicksettings.ui.HdrIndicator +import com.google.jetpackcamera.ui.components.capture.quicksettings.ui.ToggleQuickSettingsButton +import com.google.jetpackcamera.ui.controller.testing.FakeQuickSettingsController +import com.google.jetpackcamera.ui.uistate.CustomSnackbarVisuals +import com.google.jetpackcamera.ui.uistate.SingleSelectableUiState +import com.google.jetpackcamera.ui.uistate.capture.AspectRatioUiState +import com.google.jetpackcamera.ui.uistate.capture.AudioUiState +import com.google.jetpackcamera.ui.uistate.capture.CaptureModeUiState +import com.google.jetpackcamera.ui.uistate.capture.ElapsedTimeUiState +import com.google.jetpackcamera.ui.uistate.capture.FlashModeUiState +import com.google.jetpackcamera.ui.uistate.capture.FlipLensUiState +import com.google.jetpackcamera.ui.uistate.capture.HdrUiState +import com.google.jetpackcamera.ui.uistate.capture.ImageWellUiState +import com.google.jetpackcamera.ui.uistate.capture.StabilizationUiState +import com.google.jetpackcamera.ui.uistate.capture.compound.QuickSettingsUiState +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Robolectric Compose UI tests verifying content gravity rotation: + * - In-place glyph rotation (`Modifier.animateToUpright()`) on `1:1` controls and indicator icons. + * - Swapped-constraint rectangular block rotation (`Modifier.rotateLayoutToGravity()`) on + * in-hierarchy [CameraErrorDialog], landscape `PillSnackbarHost` + `ElapsedTimeText` top-edge + * banner, and compact [QuickSettingsScaffoldContent] bottom sheet. + * - Zero (`0°`) gravity rotation enforcement in [TwoSideRailLayout] and [WideShortLayout]. + */ +@OptIn(ExperimentalMaterial3Api::class) +@RunWith(RobolectricTestRunner::class) +@Config(qualifiers = "w1400dp-h1000dp-mdpi") +class ContentGravityTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private fun unclippedBoundsOf(tag: String): DpRect = + composeTestRule.onNodeWithTag(tag).getUnclippedBoundsInRoot() + + private fun clippedBoundsOf(tag: String): DpRect = + composeTestRule.onNodeWithTag(tag).getBoundsInRoot() + + private fun sampleQuickSettingsUiState(): QuickSettingsUiState.Available = + QuickSettingsUiState.Available( + aspectRatioUiState = AspectRatioUiState.Available( + selectedAspectRatio = AspectRatio.THREE_FOUR, + availableAspectRatios = listOf( + SingleSelectableUiState.SelectableUi(AspectRatio.THREE_FOUR), + SingleSelectableUiState.SelectableUi(AspectRatio.ONE_ONE), + SingleSelectableUiState.SelectableUi(AspectRatio.NINE_SIXTEEN) + ) + ), + captureModeUiState = CaptureModeUiState.Available( + selectedCaptureMode = CaptureMode.STANDARD, + availableCaptureModes = listOf( + SingleSelectableUiState.SelectableUi(CaptureMode.STANDARD), + SingleSelectableUiState.SelectableUi(CaptureMode.VIDEO_ONLY), + SingleSelectableUiState.SelectableUi(CaptureMode.IMAGE_ONLY) + ) + ), + flashModeUiState = FlashModeUiState.Available( + selectedFlashMode = FlashMode.OFF, + availableFlashModes = listOf( + SingleSelectableUiState.SelectableUi(FlashMode.OFF), + SingleSelectableUiState.SelectableUi(FlashMode.ON), + SingleSelectableUiState.SelectableUi(FlashMode.AUTO) + ), + isLowLightBoostActive = false + ), + flipLensUiState = FlipLensUiState.Available( + selectedLensFacing = LensFacing.BACK, + availableLensFacings = listOf( + SingleSelectableUiState.SelectableUi(LensFacing.BACK), + SingleSelectableUiState.SelectableUi(LensFacing.FRONT) + ) + ), + hdrUiState = HdrUiState.Available( + selectedDynamicRange = DEFAULT_HDR_DYNAMIC_RANGE, + selectedImageFormat = DEFAULT_HDR_IMAGE_OUTPUT, + isSupported = true + ) + ) + + @Test + fun squareControls_inCompactPortraitLandscape_preserveBoundsRotatingInPlace() { + val gravityState = mutableFloatStateOf(0f) + val measuredSlotSizes = mutableMapOf() + val thumbnailBitmap = createBitmap(64, 64) + val pauseSlotTag = "slot_pause_resume" + + composeTestRule.setContent { + MaterialTheme { + CompositionLocalProvider( + LocalDensity provides Density(1f), + LocalDisableAnimations provides true, + LocalGravityRotationState provides gravityState + ) { + Column { + Row { + Box(Modifier.onSizeChanged { measuredSlotSizes["flip"] = it }) { + FlipCameraButton( + modifier = Modifier.testTag(FLIP_CAMERA_BUTTON), + flipLensUiState = FlipLensUiState.Available( + selectedLensFacing = LensFacing.BACK, + availableLensFacings = listOf( + SingleSelectableUiState.SelectableUi(LensFacing.BACK), + SingleSelectableUiState.SelectableUi(LensFacing.FRONT) + ) + ), + onClick = {} + ) + } + Box( + Modifier.onSizeChanged { measuredSlotSizes["quickSettings"] = it } + ) { + ToggleQuickSettingsButton( + isOpen = false, + onClick = {} + ) + } + Box(Modifier.onSizeChanged { measuredSlotSizes["imageWell"] = it }) { + ImageWell( + imageWellUiState = ImageWellUiState.Content( + mediaDescriptor = MediaDescriptor.Content.Image( + uri = Uri.EMPTY, + thumbnail = thumbnailBitmap + ) + ), + onClick = {} + ) + } + Box(Modifier.onSizeChanged { measuredSlotSizes["pause"] = it }) { + PauseResumeToggleButton( + modifier = Modifier.testTag(pauseSlotTag), + onSetPause = {}, + currentRecordingStateProvider = { + VideoRecordingState.Active.Recording( + maxDurationMillis = 0L, + audioStreamState = AudioStreamState.Disabled, + elapsedTimeNanos = 1_000_000_000L + ) + } + ) + } + Box(Modifier.onSizeChanged { measuredSlotSizes["audio"] = it }) { + AmplitudeToggleButton( + audioUiState = AudioUiState.Enabled.On( + amplitude = 0.5, + isAudioStreamActive = true + ), + onToggleAudio = {} + ) + } + } + Box(Modifier.onSizeChanged { measuredSlotSizes["indicatorPill"] = it }) { + IndicatorPill { + HdrIndicator( + hdrUiState = HdrUiState.Available( + selectedDynamicRange = DEFAULT_HDR_DYNAMIC_RANGE, + selectedImageFormat = DEFAULT_HDR_IMAGE_OUTPUT, + isSupported = true + ) + ) + FlashModeIndicator( + flashModeUiState = FlashModeUiState.Available( + selectedFlashMode = FlashMode.ON, + availableFlashModes = listOf( + SingleSelectableUiState.SelectableUi(FlashMode.OFF), + SingleSelectableUiState.SelectableUi(FlashMode.ON) + ), + isLowLightBoostActive = false + ) + ) + StabilizationIcon( + stabilizationUiState = StabilizationUiState.Specific( + stabilizationMode = StabilizationMode.ON, + active = true + ) + ) + VideoQualityIcon( + videoQuality = VideoQuality.FHD, + modifier = Modifier.testTag(VIDEO_QUALITY_TAG) + ) + } + } + } + } + } + } + composeTestRule.waitForIdle() + + val baselineMeasuredSizes = measuredSlotSizes.toMap() + val baselineQuickSettingsSize = + composeTestRule.onNodeWithTag(QUICK_SETTINGS_DROP_DOWN).fetchSemanticsNode().size + val baselineQuickSettingsBounds = clippedBoundsOf(QUICK_SETTINGS_DROP_DOWN) + val squareTags = listOf( + FLIP_CAMERA_BUTTON, + IMAGE_WELL_TAG, + pauseSlotTag, + AUDIO_INPUT_TOGGLE + ) + val baselineSquareBounds = squareTags.associateWith { clippedBoundsOf(it) } + val baselineVideoQualityBounds = clippedBoundsOf(VIDEO_QUALITY_TAG) + + squareTags.forEach { tag -> + val bounds = baselineSquareBounds.getValue(tag) + assertWithMessage("$tag should be square at 0f") + .that(bounds.width.value) + .isWithin(TOLERANCE) + .of(bounds.height.value) + } + + for (angle in listOf(90f, -90f)) { + gravityState.floatValue = angle + composeTestRule.waitForIdle() + + // Layout pass sizes of every slot remain 100% invariant (zero sibling shift). + assertThat(measuredSlotSizes).isEqualTo(baselineMeasuredSizes) + assertThat( + composeTestRule.onNodeWithTag(QUICK_SETTINGS_DROP_DOWN).fetchSemanticsNode().size + ).isEqualTo(baselineQuickSettingsSize) + + // 1:1 square controls preserve identical square bounds in root across ±90f. + squareTags.forEach { tag -> + val rotatedBounds = clippedBoundsOf(tag) + val initialBounds = baselineSquareBounds.getValue(tag) + assertWithMessage("$tag width at $angle") + .that(rotatedBounds.width.value) + .isWithin(TOLERANCE) + .of(initialBounds.width.value) + assertWithMessage("$tag height at $angle") + .that(rotatedBounds.height.value) + .isWithin(TOLERANCE) + .of(initialBounds.height.value) + } + + // ToggleQuickSettingsButton (48x56dp narrow container) keeps its 48x56dp layout slot + // while its layer rotates in-place to 56x48dp in root at ±90f. + val rotatedQsBounds = clippedBoundsOf(QUICK_SETTINGS_DROP_DOWN) + assertThat(rotatedQsBounds.width.value) + .isWithin(TOLERANCE) + .of(baselineQuickSettingsBounds.height.value) + assertThat(rotatedQsBounds.height.value) + .isWithin(TOLERANCE) + .of(baselineQuickSettingsBounds.width.value) + + // IndicatorPill segment bounds remain completely unchanged while inner glyphs rotate. + val rotatedVqBounds = clippedBoundsOf(VIDEO_QUALITY_TAG) + assertThat(rotatedVqBounds.width.value) + .isWithin(TOLERANCE) + .of(baselineVideoQualityBounds.width.value) + assertThat(rotatedVqBounds.height.value) + .isWithin(TOLERANCE) + .of(baselineVideoQualityBounds.height.value) + } + } + + @Test + fun rectangularBlocks_inCompactPortraitLandscape_swapConstraintsWithoutClipping() { + // CameraErrorDialog in-hierarchy Surface inside a 360x740dp portrait container. + val dialogGravity = mutableFloatStateOf(0f) + var confirmClicked = false + + composeTestRule.setContent { + MaterialTheme { + CompositionLocalProvider( + LocalDensity provides Density(1f), + LocalDisableAnimations provides true, + LocalGravityRotationState provides dialogGravity + ) { + Box(modifier = Modifier.size(width = 360.dp, height = 740.dp)) { + CameraErrorDialog( + title = "Camera unavailable", + body = "Close other camera apps and try again.", + confirmButtonText = "OK", + onConfirm = { confirmClicked = true } + ) + } + } + } + } + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithTag(CAMERA_ERROR_DIALOG_TAG) + .assertIsDisplayed() + .assert(!isDialog()) + .assert(!isPopup()) + .assert(!hasAnyAncestor(isDialog())) + .assert(!hasAnyAncestor(isPopup())) + + // At 90°, Surface swaps minWidth = 280.dp into vertical portrait height >= 280.dp while + // fitting cleanly within the 360.dp width without clipping. + dialogGravity.floatValue = 90f + composeTestRule.waitForIdle() + + val titleLocalSize = + composeTestRule.onNodeWithTag(CAMERA_ERROR_DIALOG_TITLE_TAG).fetchSemanticsNode().size + val titleClipped = clippedBoundsOf(CAMERA_ERROR_DIALOG_TITLE_TAG) + val bodyLocalSize = + composeTestRule.onNodeWithTag(CAMERA_ERROR_DIALOG_BODY_TAG).fetchSemanticsNode().size + val bodyClipped = clippedBoundsOf(CAMERA_ERROR_DIALOG_BODY_TAG) + val buttonLocalSize = + composeTestRule + .onNodeWithTag(CAMERA_ERROR_DIALOG_CONFIRM_BUTTON_TAG) + .fetchSemanticsNode() + .size + val buttonClipped = clippedBoundsOf(CAMERA_ERROR_DIALOG_CONFIRM_BUTTON_TAG) + + // Rotated 90° CW: clipped root width equals unrotated local height and clipped root height + // equals unrotated local width (proving zero clipping after constraint swap). + assertThat(titleClipped.width.value).isWithin(TOLERANCE).of(titleLocalSize.height.toFloat()) + assertThat(titleClipped.height.value).isWithin(TOLERANCE).of(titleLocalSize.width.toFloat()) + assertThat(bodyClipped.width.value).isWithin(TOLERANCE).of(bodyLocalSize.height.toFloat()) + assertThat(bodyClipped.height.value).isWithin(TOLERANCE).of(bodyLocalSize.width.toFloat()) + assertThat(buttonClipped.width.value) + .isWithin(TOLERANCE) + .of(buttonLocalSize.height.toFloat()) + assertThat(buttonClipped.height.value) + .isWithin(TOLERANCE) + .of(buttonLocalSize.width.toFloat()) + + // Body spans >= 232.dp vertically (280.dp min card width - 48.dp card content padding), + // and confirm button hit target works cleanly in rotated coordinates. + assertThat(bodyClipped.height.value).isAtLeast(232f - TOLERANCE) + assertThat(titleClipped.left.value).isAtLeast(0f) + assertThat(titleClipped.right.value).isAtMost(360f) + composeTestRule.onNodeWithTag(CAMERA_ERROR_DIALOG_CONFIRM_BUTTON_TAG).performClick() + assertThat(confirmClicked).isTrue() + } + + @Test + fun landscapeTopEdgeBanner_inCompactPortrait_anchorsToTopEdgeInLtrAndRtl() { + var activeRotation by mutableStateOf(DeviceRotation.Rotated270) + var layoutDirection by mutableStateOf(LayoutDirection.Ltr) + + composeTestRule.setContent { + val scaffoldState = rememberCompactPortraitScaffoldState() + LaunchedEffect(scaffoldState.snackbarHostState) { + scaffoldState.snackbarHostState.showSnackbar( + CustomSnackbarVisuals( + message = "Storage nearly full", + actionLabel = null, + withDismissAction = false, + duration = SnackbarDuration.Indefinite, + isError = false + ) + ) + } + MaterialTheme { + CompositionLocalProvider( + LocalDensity provides Density(1f), + LocalDisableAnimations provides true, + LocalLayoutDirection provides layoutDirection + ) { + Box(modifier = Modifier.size(width = 740.dp, height = 360.dp)) { + CompactPortraitLayout( + scaffoldState = scaffoldState, + deviceRotation = activeRotation, + windowRotation = activeRotation, + viewfinder = { mod -> Box(mod) }, + captureButton = { mod -> Box(mod.size(80.dp)) }, + imageWell = { mod -> Box(mod.size(48.dp)) }, + flipCameraButton = { mod -> Box(mod.size(48.dp)) }, + zoomLevelDisplay = { mod -> + Box( + mod.size(width = 160.dp, height = 48.dp) + ) + }, + elapsedTimeDisplay = { mod -> + ElapsedTimeText( + modifier = mod, + elapsedTimeUiStateProvider = { + ElapsedTimeUiState.Enabled( + elapsedTimeNanos = 15_000_000_000L + ) + } + ) + }, + quickSettingsButton = { mod -> Box(mod.size(48.dp)) }, + indicatorRow = { mod -> Box(mod.size(width = 120.dp, height = 36.dp)) }, + captureModeToggle = { mod -> + Box( + mod.size(width = 96.dp, height = 48.dp) + ) + }, + quickSettingsOverlay = {} + ) + } + } + } + } + + for (dir in listOf(LayoutDirection.Ltr, LayoutDirection.Rtl)) { + for (rotation in listOf(DeviceRotation.Rotated270, DeviceRotation.Rotated90)) { + layoutDirection = dir + activeRotation = rotation + composeTestRule.waitForIdle() + + val snackbarClipped = clippedBoundsOf(SNACKBAR_NODE_TAG) + val snackbarUnclipped = unclippedBoundsOf(SNACKBAR_NODE_TAG) + val elapsedClipped = clippedBoundsOf(ELAPSED_TIME_TAG) + val elapsedUnclipped = unclippedBoundsOf(ELAPSED_TIME_TAG) + + // Banner sits at the physical top edge (y near 0.dp) of the 740x360dp landscape + // window with PillSnackbarHost above ElapsedTimeText and zero clipping. + assertWithMessage("Snackbar top at $rotation / $dir") + .that(snackbarUnclipped.top.value) + .isWithin(TOLERANCE) + .of(0f) + assertWithMessage("Snackbar horizontal orientation at $rotation / $dir") + .that(snackbarUnclipped.width.value) + .isGreaterThan(snackbarUnclipped.height.value) + assertWithMessage("ElapsedTime horizontal orientation at $rotation / $dir") + .that(elapsedUnclipped.width.value) + .isGreaterThan(elapsedUnclipped.height.value) + assertWithMessage("Snackbar sits above ElapsedTime at $rotation / $dir") + .that(snackbarUnclipped.bottom.value) + .isAtMost(elapsedUnclipped.top.value + TOLERANCE) + assertThat(snackbarClipped.width.value) + .isWithin(TOLERANCE) + .of(snackbarUnclipped.width.value) + assertThat(elapsedClipped.width.value) + .isWithin(TOLERANCE) + .of(elapsedUnclipped.width.value) + } + } + } + + @Test + fun quickSettingsSheetContent_inLandscapeGravity_swapsConstraintsViaRotateLayoutToGravity() { + var deviceRotation by mutableStateOf(DeviceRotation.Natural) + val fakeController = FakeQuickSettingsController() + var selectedFlash: FlashMode? = null + fakeController.setFlashAction = { selectedFlash = it } + + composeTestRule.setContent { + val scaffoldState = rememberCompactPortraitScaffoldState( + initialValue = SheetValue.Expanded + ) + MaterialTheme { + CompositionLocalProvider( + LocalDensity provides Density(1f), + LocalDisableAnimations provides true + ) { + Box(modifier = Modifier.size(width = 412.dp, height = 915.dp)) { + CompactPortraitLayout( + scaffoldState = scaffoldState, + deviceRotation = deviceRotation, + windowRotation = DeviceRotation.Natural, + viewfinder = { mod -> Box(mod) }, + captureButton = { mod -> Box(mod.size(80.dp)) }, + imageWell = { mod -> Box(mod.size(48.dp)) }, + flipCameraButton = { mod -> Box(mod.size(48.dp)) }, + zoomLevelDisplay = { mod -> + Box( + mod.size(width = 160.dp, height = 48.dp) + ) + }, + elapsedTimeDisplay = {}, + quickSettingsButton = { mod -> Box(mod.size(48.dp)) }, + indicatorRow = { mod -> Box(mod.size(width = 120.dp, height = 36.dp)) }, + captureModeToggle = { mod -> + Box( + mod.size(width = 96.dp, height = 48.dp) + ) + }, + quickSettingsOverlay = { mod -> + QuickSettingsScaffoldContent( + modifier = mod, + quickSettingsUiState = sampleQuickSettingsUiState(), + onNavigateToSettings = {}, + quickSettingsController = fakeController + ) + } + ) + } + } + } + } + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithTag(QUICK_SETTINGS_BOTTOM_SHEET) + .assertIsDisplayed() + .assert(!isDialog()) + .assert(!isPopup()) + val portraitSheetBounds = clippedBoundsOf(QUICK_SETTINGS_BOTTOM_SHEET) + assertThat(portraitSheetBounds.width.value).isWithin(TOLERANCE).of(412f) + + // Rotate device to landscape (Rotated270 -> +90° gravity): sheet swaps constraints so its + // local width is capped at 380.dp and local height matches 412.dp, while rotated bounds in + // root span 412.dp wide by <= 380.dp tall. + deviceRotation = DeviceRotation.Rotated270 + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithTag(QUICK_SETTINGS_BOTTOM_SHEET).assertIsDisplayed() + val landscapeSheetLocalSize = + composeTestRule.onNodeWithTag(QUICK_SETTINGS_BOTTOM_SHEET).fetchSemanticsNode().size + val landscapeSheetBounds = clippedBoundsOf(QUICK_SETTINGS_BOTTOM_SHEET) + assertThat(landscapeSheetLocalSize.width).isAtMost(380) + assertThat(landscapeSheetLocalSize.height).isEqualTo(412) + assertThat(landscapeSheetBounds.width.value).isWithin(TOLERANCE).of(412f) + assertThat(landscapeSheetBounds.height.value).isAtMost(380f + TOLERANCE) + + // Rotated hit targets inside QuickSettingsScaffoldContent remain interactive. + composeTestRule.onNodeWithTag(BTN_QUICK_SETTINGS_FLASH_OPTION_ON).performClick() + assertThat(selectedFlash).isEqualTo(FlashMode.ON) + } + + @Test + fun twoSideRailAndWideShortLayouts_inLandscape_keepZeroGravityRotation() { + var useWideShort by mutableStateOf(false) + var showErrorDialog by mutableStateOf(true) + var observedRailGravity: Float? = null + val fakeController = FakeQuickSettingsController() + + @Composable + fun buildRailSlots(): CaptureSlots = PlaceholderCaptureSlots.sideRail( + isCompactHeight = useWideShort, + includeElapsedTime = true + ).copy( + quickSettingsOverlay = { mod -> + QuickSettingsScaffoldContent( + modifier = mod, + quickSettingsUiState = sampleQuickSettingsUiState(), + onNavigateToSettings = {}, + quickSettingsController = fakeController + ) + }, + errorDialog = { mod -> + if (showErrorDialog) { + CameraErrorDialog( + modifier = mod, + title = "Camera error", + body = "Try again", + confirmButtonText = "OK", + onConfirm = {} + ) + } + } + ) + + composeTestRule.setContent { + val containerSize = if (useWideShort) { + DpSize(800.dp, 400.dp) + } else { + DpSize(1280.dp, 800.dp) + } + val ambientGravity = remember { mutableFloatStateOf(90f) } + MaterialTheme { + CompositionLocalProvider( + LocalDensity provides Density(1f), + LocalDisableAnimations provides true, + LocalDeviceRotation provides DeviceRotation.Rotated270, + LocalWindowRotation provides DeviceRotation.Rotated270, + LocalGravityRotationState provides ambientGravity + ) { + Box(modifier = Modifier.size(containerSize)) { + val slots = buildRailSlots() + if (useWideShort) { + WideShortLayout( + slots = slots, + viewfinder = { mod -> + observedRailGravity = LocalGravityRotationState.current.value + PlaceholderViewfinder(modifier = mod) + }, + isQuickSettingsOpen = !showErrorDialog, + isErrorDialogShowing = showErrorDialog, + tagQuickSettingsCard = false + ) + } else { + TwoSideRailLayout( + slots = slots, + viewfinder = { mod -> + observedRailGravity = LocalGravityRotationState.current.value + PlaceholderViewfinder(modifier = mod) + }, + isQuickSettingsOpen = !showErrorDialog, + isErrorDialogShowing = showErrorDialog, + tagQuickSettingsCard = false + ) + } + } + } + } + } + + for (wideShort in listOf(false, true)) { + useWideShort = wideShort + showErrorDialog = true + composeTestRule.waitForIdle() + + // Even with ambient 90f gravity and Rotated270 device/window rotations, rail layouts + // enforce 0f gravity rotation. + assertThat(observedRailGravity).isEqualTo(0f) + + val titleBounds = clippedBoundsOf(CAMERA_ERROR_DIALOG_TITLE_TAG) + val bodyBounds = clippedBoundsOf(CAMERA_ERROR_DIALOG_BODY_TAG) + val confirmBounds = clippedBoundsOf(CAMERA_ERROR_DIALOG_CONFIRM_BUTTON_TAG) + assertThat(titleBounds.top.value).isLessThan(confirmBounds.top.value) + assertThat(bodyBounds.width.value).isGreaterThan(bodyBounds.height.value) + + // Open Quick Settings floating card and verify it also remains unrotated (0°). + showErrorDialog = false + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithTag(QUICK_SETTINGS_BOTTOM_SHEET).assertIsDisplayed() + val flashRowBounds = clippedBoundsOf(ROW_QUICK_SETTINGS_FLASH) + val captureModeRowBounds = clippedBoundsOf(ROW_QUICK_SETTINGS_CAPTURE_MODE) + assertThat(flashRowBounds.top.value).isLessThan(captureModeRowBounds.top.value) + assertThat(flashRowBounds.width.value).isGreaterThan(flashRowBounds.height.value) + composeTestRule.onNodeWithTag(SETTINGS_BUTTON).performScrollTo().assertIsDisplayed() + } + } + + @Config(qualifiers = "sw800dp-w1400dp-h1000dp-mdpi") + @Test + fun compactHorizontalSplitScreen_rotatesLayout90DegreesAndKeepsOverlaysUpright() { + var observedMode: CameraLayoutMode? = null + var observedInteriorGravity: Float? = null + + composeTestRule.setContent { + MaterialTheme { + CompositionLocalProvider( + LocalDensity provides Density(1f), + LocalDisableAnimations provides true + ) { + PreviewLayout( + modifier = Modifier.size(width = 800.dp, height = 400.dp), + deviceRotation = DeviceRotation.Natural, + windowRotation = DeviceRotation.Natural, + viewfinder = { mod -> + observedMode = LocalCameraLayoutMode.current + observedInteriorGravity = LocalGravityRotationState.current.value + PlaceholderViewfinder(modifier = mod) + }, + captureButton = { mod -> + Box(mod.size(80.dp).testTag(CAPTURE_BUTTON)) + }, + imageWell = { mod -> + Box(mod.size(48.dp).testTag(IMAGE_WELL_TAG)) + }, + flipCameraButton = { mod -> + Box(mod.size(48.dp).testTag(FLIP_CAMERA_BUTTON)) + }, + zoomLevelDisplay = { mod -> + Box( + mod.size( + width = 160.dp, + height = 48.dp + ).testTag(ZOOM_BUTTON_ROW_TAG) + ) + }, + elapsedTimeDisplay = { mod -> + ElapsedTimeText( + modifier = mod, + elapsedTimeUiStateProvider = { + ElapsedTimeUiState.Enabled(elapsedTimeNanos = 0L) + } + ) + }, + quickSettingsButton = { mod -> + Box(mod.size(48.dp).testTag(QUICK_SETTINGS_DROP_DOWN)) + }, + indicatorRow = {}, + captureModeToggle = { mod -> + Box(mod.size(width = 96.dp, height = 48.dp)) + }, + quickSettingsOverlay = {}, + debugOverlay = {}, + debugVisibilityWrapper = { content -> content() }, + screenFlashOverlay = {}, + snackBar = { _, _ -> }, + errorDialog = { mod -> + CameraErrorDialog( + modifier = mod, + title = "Camera error", + body = "Try again", + confirmButtonText = "OK", + onConfirm = {} + ) + } + ) + } + } + } + composeTestRule.waitForIdle() + + assertThat(observedMode).isEqualTo(CameraLayoutMode.COMPACT_HORIZONTAL) + assertThat(((observedInteriorGravity!! % 360f) + 360f) % 360f).isEqualTo(90f) + + // Inside the -90° CCW rotated CompactPortraitLayout, +90° CW interior gravity brings + // rectangular overlays back to 0° upright in the 800x400dp screen window. + val elapsedBounds = clippedBoundsOf(ELAPSED_TIME_TAG) + assertThat(elapsedBounds.width.value).isGreaterThan(elapsedBounds.height.value) + + val titleBounds = clippedBoundsOf(CAMERA_ERROR_DIALOG_TITLE_TAG) + val bodyBounds = clippedBoundsOf(CAMERA_ERROR_DIALOG_BODY_TAG) + val confirmBounds = clippedBoundsOf(CAMERA_ERROR_DIALOG_CONFIRM_BUTTON_TAG) + assertThat(titleBounds.top.value).isLessThan(confirmBounds.top.value) + assertThat(bodyBounds.width.value).isGreaterThan(bodyBounds.height.value) + } + + private companion object { + const val TOLERANCE = 0.5f + } +} From f80763edd97286b9ea958f0141e54563dd61b6e9 Mon Sep 17 00:00:00 2001 From: Kimberly Crevecoeur Date: Thu, 12 Nov 2026 07:42:25 -0800 Subject: [PATCH 3/5] Use scrollable QuickSettingsScaffoldContent inside side-rail floating card --- .../ui/components/capture/CaptureScaffold.kt | 28 ++++++------------- 1 file changed, 8 insertions(+), 20 deletions(-) diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureScaffold.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureScaffold.kt index 3e3cbadf50..67b1914fa1 100644 --- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureScaffold.kt +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureScaffold.kt @@ -66,7 +66,6 @@ import com.google.jetpackcamera.model.DeviceRotation import com.google.jetpackcamera.model.ExternalCaptureMode import com.google.jetpackcamera.model.LensFacing import com.google.jetpackcamera.model.LensToZoom -import com.google.jetpackcamera.ui.components.capture.quicksettings.QuickSettingsContent import com.google.jetpackcamera.ui.components.capture.quicksettings.QuickSettingsScaffoldContent import com.google.jetpackcamera.ui.components.capture.quicksettings.ui.FlashModeIndicator import com.google.jetpackcamera.ui.components.capture.quicksettings.ui.HdrIndicator @@ -735,25 +734,14 @@ object CaptureScaffoldDefaults { showMoreSettingsButton: Boolean = true ) { if (controller != null) { - if (LocalActiveCaptureLayout.current == "CompactPortraitLayout") { - QuickSettingsScaffoldContent( - quickSettingsUiState = uiState, - onNavigateToSettings = onNavigateToSettings, - quickSettingsController = controller, - modifier = modifier, - showMoreSettingsButton = showMoreSettingsButton - ) - } else if (uiState is QuickSettingsUiState.Available) { - QuickSettingsContent( - quickSettingsUiState = uiState, - quickSettingsController = controller, - onNavigateToSettings = onNavigateToSettings, - modifier = modifier - .padding(vertical = 20.dp) - .testTag(QUICK_SETTINGS_BOTTOM_SHEET), - showMoreSettingsButton = showMoreSettingsButton - ) - } + val isFloatingCard = LocalActiveCaptureLayout.current != "CompactPortraitLayout" + QuickSettingsScaffoldContent( + quickSettingsUiState = uiState, + onNavigateToSettings = onNavigateToSettings, + quickSettingsController = controller, + modifier = if (isFloatingCard) modifier.padding(top = 20.dp) else modifier, + showMoreSettingsButton = showMoreSettingsButton + ) } } From 2a48a9777a78ac4684b817ee7ccff094c9bb9925 Mon Sep 17 00:00:00 2001 From: Kimberly Crevecoeur Date: Thu, 12 Nov 2026 08:33:25 -0800 Subject: [PATCH 4/5] Rotate ZoomBar labels with content gravity and retain previous gravity at 180 degrees --- .../jetpackcamera/model/DeviceRotation.kt | 12 ++- .../jetpackcamera/model/DeviceRotationTest.kt | 13 ++- .../capture/CompactPortraitLayout.kt | 11 ++- .../components/capture/RotationModifiers.kt | 45 +++++++++- .../components/capture/ZoomBarComponents.kt | 34 ++++++- .../components/capture/ContentGravityTest.kt | 88 +++++++++++++++++++ .../capture/RotationModifiersTest.kt | 47 ++++++++++ 7 files changed, 233 insertions(+), 17 deletions(-) diff --git a/core/model/src/main/java/com/google/jetpackcamera/model/DeviceRotation.kt b/core/model/src/main/java/com/google/jetpackcamera/model/DeviceRotation.kt index 1b55c13d07..2ee61e1d62 100644 --- a/core/model/src/main/java/com/google/jetpackcamera/model/DeviceRotation.kt +++ b/core/model/src/main/java/com/google/jetpackcamera/model/DeviceRotation.kt @@ -53,16 +53,20 @@ enum class DeviceRotation { * Returns the clockwise rotation in degrees needed inside a window at [windowRotation] so that * UI elements remain oriented upright against physical gravity (`this` device rotation). * - * An upside-down (`180°`) delta is mapped to `0` so camera controls and layouts never flip - * upside-down. + * An upside-down (`180°`) delta returns [fallbackOn180Degrees] (defaulting to `0`) so camera + * controls and layouts never flip upside-down and stateful callers can retain their previous + * upright gravity angle. */ - fun toGravityRotationDegrees(windowRotation: DeviceRotation): Int { + fun toGravityRotationDegrees( + windowRotation: DeviceRotation, + fallbackOn180Degrees: Int = 0 + ): Int { val rawDelta = ( (windowRotation.toClockwiseRotationDegrees() - toClockwiseRotationDegrees()) % 360 + 360 ) % 360 - return if (rawDelta == 180) 0 else rawDelta + return if (rawDelta == 180) fallbackOn180Degrees else rawDelta } /** diff --git a/core/model/src/test/java/com/google/jetpackcamera/model/DeviceRotationTest.kt b/core/model/src/test/java/com/google/jetpackcamera/model/DeviceRotationTest.kt index 2f6b686e5e..52465c3fc8 100644 --- a/core/model/src/test/java/com/google/jetpackcamera/model/DeviceRotationTest.kt +++ b/core/model/src/test/java/com/google/jetpackcamera/model/DeviceRotationTest.kt @@ -77,17 +77,24 @@ class DeviceRotationTest { ) ).isEqualTo(90) - // Upside-down delta (180°) clamps to 0° so UI never flips upside-down + // Upside-down delta (180°) defaults to 0° and honors stateful fallbackOn180Degrees assertThat( DeviceRotation.Rotated180.toGravityRotationDegrees( windowRotation = DeviceRotation.Natural ) ).isEqualTo(0) + assertThat( + DeviceRotation.Rotated180.toGravityRotationDegrees( + windowRotation = DeviceRotation.Natural, + fallbackOn180Degrees = 270 + ) + ).isEqualTo(270) assertThat( DeviceRotation.Rotated90.toGravityRotationDegrees( - windowRotation = DeviceRotation.Rotated270 + windowRotation = DeviceRotation.Rotated270, + fallbackOn180Degrees = 90 ) - ).isEqualTo(0) + ).isEqualTo(90) } @Test diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CompactPortraitLayout.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CompactPortraitLayout.kt index 26e7f2bca8..c813f1734b 100644 --- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CompactPortraitLayout.kt +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CompactPortraitLayout.kt @@ -364,7 +364,10 @@ internal fun CompactPortraitLayout( } else { deviceRotation } - val windowGravityDeg = effectiveDeviceRotation.toGravityRotationDegrees(windowRotation) + val windowGravityDeg = rememberStickyGravityTargetDegrees( + deviceRotation = effectiveDeviceRotation, + windowRotation = windowRotation + ) val effectiveWindowRotation = when { !shouldCounterRotate -> DeviceRotation.Natural windowGravityDeg == 90 -> DeviceRotation.Rotated90 @@ -384,8 +387,10 @@ internal fun CompactPortraitLayout( deviceRotation = effectiveDeviceRotation, windowRotation = windowRotation ) - val targetGravityDegrees = - effectiveDeviceRotation.toGravityRotationDegrees(containerFrameRotation) + val targetGravityDegrees = rememberStickyGravityTargetDegrees( + deviceRotation = effectiveDeviceRotation, + windowRotation = containerFrameRotation + ) val isLandscapeGravity = targetGravityDegrees == CompactPortraitTokens.LANDSCAPE_CCW_GRAVITY_DEGREES || targetGravityDegrees == CompactPortraitTokens.LANDSCAPE_CW_GRAVITY_DEGREES diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/RotationModifiers.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/RotationModifiers.kt index ebf5253d6b..43b70c9001 100644 --- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/RotationModifiers.kt +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/RotationModifiers.kt @@ -40,6 +40,7 @@ import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -171,11 +172,40 @@ fun rememberDeviceRotation(): DeviceRotation { return deviceRotation } +/** + * Remembers the target clockwise gravity compensation angle in degrees (`0`, `90`, or `270`), + * holding the previous non-inverted gravity angle whenever the relative delta between + * [windowRotation] and [deviceRotation] is `180°` so symbols and overlays never flip upside-down or + * snap back to `0°` while held at `180°`. + */ +@Composable +internal fun rememberStickyGravityTargetDegrees( + deviceRotation: DeviceRotation = LocalDeviceRotation.current, + windowRotation: DeviceRotation = LocalWindowRotation.current +): Int { + var lastNon180Degrees by remember { mutableIntStateOf(0) } + var previousWindowRotation by remember { mutableStateOf(windowRotation) } + if (previousWindowRotation != windowRotation) { + previousWindowRotation = windowRotation + lastNon180Degrees = 0 + } + val resolved = deviceRotation.toGravityRotationDegrees( + windowRotation = windowRotation, + fallbackOn180Degrees = lastNon180Degrees + ) + if (resolved != lastNon180Degrees) { + lastNon180Degrees = resolved + } + return resolved +} + /** * Remembers a single continuous clockwise rotation [State] in degrees that keeps UI elements * oriented upright against gravity inside the current window: - * - Uses shortest-path angle interpolation (`±90°`) so transitions across `0° <-> 270°` never spin - * `270°` the long way around. + * - Holds the previous non-`180°` gravity target while the device is at `180°` relative to the + * window so icons never flip upside-down or snap back to `0°`. + * - Uses shortest-path angle interpolation (`±90°`) and routes `180°` landscape-to-landscape + * transitions (`90° <-> 270°`) through `0°` upright rather than `180°` upside-down. * - Snaps immediately when [windowRotation] changes (since the OS is already animating the window * rotation) or when [LocalDisableAnimations] is `true`. */ @@ -184,7 +214,10 @@ fun rememberGravityRotationState( deviceRotation: DeviceRotation = LocalDeviceRotation.current, windowRotation: DeviceRotation = LocalWindowRotation.current ): State { - val targetClockwiseDegrees = deviceRotation.toGravityRotationDegrees(windowRotation) + val targetClockwiseDegrees = rememberStickyGravityTargetDegrees( + deviceRotation = deviceRotation, + windowRotation = windowRotation + ) var continuousTarget by remember { mutableFloatStateOf(targetClockwiseDegrees.toFloat()) } var previousWindowRotation by remember { mutableStateOf(windowRotation) } @@ -195,7 +228,11 @@ fun rememberGravityRotationState( val currentMod = ((continuousTarget % 360f) + 360f) % 360f val rawDelta = ((targetClockwiseDegrees - currentMod + 540f) % 360f) - 180f - val delta = if (rawDelta == -180f) 180f else rawDelta + val delta = if (rawDelta == -180f || rawDelta == 180f) { + if (currentMod >= 180f) 180f else -180f + } else { + rawDelta + } if (delta != 0f) { continuousTarget += delta } diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/ZoomBarComponents.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/ZoomBarComponents.kt index 8d1aecf062..21a887806c 100644 --- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/ZoomBarComponents.kt +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/ZoomBarComponents.kt @@ -50,6 +50,7 @@ import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.drawOutline import androidx.compose.ui.graphics.drawscope.translate +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.lerp import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource @@ -76,6 +77,11 @@ import java.text.DecimalFormatSymbols import java.util.Locale import kotlin.math.abs import kotlin.math.floor +import kotlin.math.sin + +private const val MIN_ROTATED_LABEL_SCALE = 0.6f +private val RotatedLabelPillMargin = 6.dp +private val UnrotatedLabelPillMargin = 4.dp /** * Styling configuration for [ZoomBar]. @@ -522,8 +528,7 @@ private fun ZoomBarItem( fontScale = density.fontScale.coerceAtMost(ZoomBarDefaults.MAX_LABEL_FONT_SCALE) ) ) { - // TODO(#619): Apply Modifier.animateToUpright() to keep labels upright on rotation - // (https://github.com/google/jetpack-camera-app/pull/619). + val gravityRotationState = LocalGravityRotationState.current BasicText( text = labelText, style = MaterialTheme.typography.labelLarge.copy( @@ -538,7 +543,30 @@ private fun ZoomBarItem( }, modifier = Modifier .wrapContentSize(unbounded = true) - .padding(horizontal = 2.dp), + .padding(horizontal = 2.dp) + .graphicsLayer { + val rotation = gravityRotationState.value + rotationZ = rotation + if (size.width > 0f) { + val rotationRadians = Math.toRadians(rotation.toDouble()) + val unrotatedMaxSpanPx = + (ZoomBarDefaults.SlotSize - UnrotatedLabelPillMargin).toPx() + val rotatedMaxSpanPx = + (ZoomBarDefaults.IndicatorHeight - RotatedLabelPillMargin).toPx() + val maxSpanPx = lerp( + unrotatedMaxSpanPx, + rotatedMaxSpanPx, + abs(sin(rotationRadians)).toFloat() + ) + val scale = if (size.width > maxSpanPx) { + (maxSpanPx / size.width).coerceAtLeast(MIN_ROTATED_LABEL_SCALE) + } else { + 1f + } + scaleX = scale + scaleY = scale + } + }, softWrap = false ) } diff --git a/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/ContentGravityTest.kt b/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/ContentGravityTest.kt index 84191b9793..23bfd0a56f 100644 --- a/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/ContentGravityTest.kt +++ b/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/ContentGravityTest.kt @@ -46,6 +46,7 @@ import androidx.compose.ui.test.isDialog import androidx.compose.ui.test.isPopup import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo import androidx.compose.ui.unit.Density @@ -88,6 +89,7 @@ import com.google.jetpackcamera.ui.uistate.capture.FlipLensUiState import com.google.jetpackcamera.ui.uistate.capture.HdrUiState import com.google.jetpackcamera.ui.uistate.capture.ImageWellUiState import com.google.jetpackcamera.ui.uistate.capture.StabilizationUiState +import com.google.jetpackcamera.ui.uistate.capture.ZoomControlUiState import com.google.jetpackcamera.ui.uistate.capture.compound.QuickSettingsUiState import org.junit.Rule import org.junit.Test @@ -864,6 +866,92 @@ class ContentGravityTest { assertThat(bodyBounds.width.value).isGreaterThan(bodyBounds.height.value) } + @Config(qualifiers = "w412dp-h915dp-mdpi") + @Test + fun zoomBarLabelsAndCompactLandscapeGravity_rotateUprightAndHoldPreviousGravityAt180() { + var deviceRotation by mutableStateOf(DeviceRotation.Natural) + val state = CaptureScaffoldState(isInitiallyExpanded = false) + val fakeController = FakeQuickSettingsController() + val zoomUiState = ZoomControlUiState.Enabled( + zoomLevels = listOf(0.5f, 1.5f, 2.0f), + primaryLensFacing = LensFacing.BACK, + initialZoomRatio = 1.5f, + primaryZoomRatio = 1.5f + ) + + composeTestRule.setContent { + MaterialTheme { + CompositionLocalProvider( + LocalDensity provides Density(1f), + LocalDisableAnimations provides true + ) { + CompactPortraitLayout( + slots = PlaceholderCaptureSlots.sideRail().copy( + zoomControls = { mod -> + ZoomBar( + zoomControlUiState = zoomUiState, + onChangeZoom = {}, + modifier = mod + ) + }, + elapsedTimeDisplay = { mod -> + ElapsedTimeText( + modifier = mod.testTag(ELAPSED_TIME_TAG), + elapsedTimeUiStateProvider = { + ElapsedTimeUiState.Enabled(elapsedTimeNanos = 0L) + } + ) + }, + quickSettingsOverlay = { mod -> + CaptureScaffoldDefaults.QuickSettings( + modifier = mod, + uiState = sampleQuickSettingsUiState(), + controller = fakeController, + onNavigateToSettings = {} + ) + } + ), + viewfinder = { mod -> PlaceholderViewfinder(modifier = mod) }, + state = state, + deviceRotation = deviceRotation, + windowRotation = DeviceRotation.Natural + ) + } + } + } + composeTestRule.waitForIdle() + + // Natural (0°): record baseline unrotated label bounds (width != height). + val label0 = composeTestRule.onNodeWithText("1.5\u00D7", useUnmergedTree = true) + .getBoundsInRoot() + assertThat(label0.width.value).isNotEqualTo(label0.height.value) + + // Rotate 90° CW (Rotated90 -> 270° gravity): graphicsLayer swaps the label's screen-space + // width and height by 90°, and LandscapeTopEdgeBanner anchors ElapsedTimeText to the left + // landscape top edge. + deviceRotation = DeviceRotation.Rotated90 + composeTestRule.waitForIdle() + + val label90 = composeTestRule.onNodeWithText("1.5\u00D7", useUnmergedTree = true) + .getBoundsInRoot() + assertThat(label90.width.value).isWithin(TOLERANCE).of(label0.height.value) + assertThat(label90.height.value).isWithin(TOLERANCE).of(label0.width.value) + val elapsedAt90 = clippedBoundsOf(ELAPSED_TIME_TAG) + assertThat(elapsedAt90.left.value).isLessThan(100f) + + // Rotate further to Rotated180: both ZoomBar label and LandscapeTopEdgeBanner maintain + // the previous 270° gravity instead of snapping back to 0°. + deviceRotation = DeviceRotation.Rotated180 + composeTestRule.waitForIdle() + + val label180 = composeTestRule.onNodeWithText("1.5\u00D7", useUnmergedTree = true) + .getBoundsInRoot() + assertThat(label180.width.value).isWithin(TOLERANCE).of(label90.width.value) + assertThat(label180.height.value).isWithin(TOLERANCE).of(label90.height.value) + val elapsedAt180 = clippedBoundsOf(ELAPSED_TIME_TAG) + assertThat(elapsedAt180.left.value).isWithin(TOLERANCE).of(elapsedAt90.left.value) + } + private companion object { const val TOLERANCE = 0.5f } diff --git a/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/RotationModifiersTest.kt b/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/RotationModifiersTest.kt index 3cc5ee965b..86b98de5a7 100644 --- a/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/RotationModifiersTest.kt +++ b/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/RotationModifiersTest.kt @@ -86,6 +86,53 @@ class RotationModifiersTest { assertThat(currentDegrees).isEqualTo(90f) } + @Test + fun rememberGravityRotationState_maintainsPreviousGravityAt180AndRoutesThroughUprightZero() { + var deviceRotation by mutableStateOf(DeviceRotation.Natural) + var currentDegrees = 0f + + composeTestRule.setContent { + CompositionLocalProvider(LocalDisableAnimations provides true) { + val state = rememberGravityRotationState( + deviceRotation = deviceRotation, + windowRotation = DeviceRotation.Natural + ) + currentDegrees = state.value + } + } + composeTestRule.waitForIdle() + assertThat(currentDegrees).isEqualTo(0f) + + // Direct 0° -> 180° stays at previous gravity (0°). + deviceRotation = DeviceRotation.Rotated180 + composeTestRule.waitForIdle() + assertThat(currentDegrees).isEqualTo(0f) + + // 0° -> Rotated90 (-90°) -> Rotated180 holds -90° instead of snapping back to 0°. + deviceRotation = DeviceRotation.Rotated90 + composeTestRule.waitForIdle() + assertThat(currentDegrees).isEqualTo(-90f) + + deviceRotation = DeviceRotation.Rotated180 + composeTestRule.waitForIdle() + assertThat(currentDegrees).isEqualTo(-90f) + + // Continuing from Rotated180 to Rotated270 (+90°) sweeps +180° through 0° upright to +90°. + deviceRotation = DeviceRotation.Rotated270 + composeTestRule.waitForIdle() + assertThat(currentDegrees).isEqualTo(90f) + + // Re-entering Rotated180 from Rotated270 holds +90°; continuing to Rotated90 sweeps -180° + // back through 0° upright to -90°. + deviceRotation = DeviceRotation.Rotated180 + composeTestRule.waitForIdle() + assertThat(currentDegrees).isEqualTo(90f) + + deviceRotation = DeviceRotation.Rotated90 + composeTestRule.waitForIdle() + assertThat(currentDegrees).isEqualTo(-90f) + } + @Test fun rotateLayoutToGravity_swapsOuterDimensionsAt90And270Degrees() { var outerSize = IntSize.Zero From d085dde49b4ef82e794a181a9f546d374f04f819 Mon Sep 17 00:00:00 2001 From: Kimberly Crevecoeur Date: Thu, 12 Nov 2026 08:41:09 -0800 Subject: [PATCH 5/5] Rotate ToggleSwitch icons upright with content gravity --- .../ui/components/capture/ToggleSwitch.kt | 40 +++++--- .../components/capture/ContentGravityTest.kt | 93 +++++++++++++++++++ 2 files changed, 120 insertions(+), 13 deletions(-) diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/ToggleSwitch.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/ToggleSwitch.kt index 6244c8e366..c22fd035a3 100644 --- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/ToggleSwitch.kt +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/ToggleSwitch.kt @@ -43,6 +43,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.clipRect +import androidx.compose.ui.graphics.drawscope.rotate import androidx.compose.ui.graphics.drawscope.translate import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.input.pointer.pointerInput @@ -144,9 +145,10 @@ fun ToggleSwitch( label = "thumbColor" ) - // --- 4. Icon Painters --- + // --- 4. Icon Painters & Gravity State --- val offIconPainter = leftIcon?.let { painterResource(id = it) } val onIconPainter = rightIcon?.let { painterResource(id = it) } + val gravityRotationState = LocalGravityRotationState.current // --- 5. Gesture Handlers --- val draggableState = rememberDraggableState { delta -> @@ -243,7 +245,8 @@ fun ToggleSwitch( disableColor.copy( alpha = DISABLED_ICON_ALPHA ) - } + }, + iconRotationDegrees = gravityRotationState.value ) } } @@ -313,19 +316,22 @@ private fun DrawScope.drawSwitchTrack(color: Color, cornerRadius: CornerRadius) } /** - * Draws the 'Off' and 'On' icons, clipping them based on the thumb's position. + * Draws the 'Off' and 'On' icons, rotating each icon around its own center by + * [iconRotationDegrees] while keeping the two-tone thumb clip boundary in track coordinates. */ -private fun DrawScope.drawClippedSwitchIcons( +internal fun DrawScope.drawClippedSwitchIcons( thumbRect: Rect, iconSizePx: Float, iconPadding: Float, leftIconPainter: Painter?, rightIconPainter: Painter?, offIconColor: Color, - onIconColor: Color + onIconColor: Color, + iconRotationDegrees: Float = 0f ) { val iconY = iconPadding val iconDrawSize = Size(iconSizePx, iconSizePx) + val iconPivot = Offset(iconSizePx / 2f, iconSizePx / 2f) // --- Draw the OFF Icon (Left Side) --- if (leftIconPainter != null) { @@ -334,8 +340,10 @@ private fun DrawScope.drawClippedSwitchIcons( // Draw the base (unselected) icon first translate(left = offIconX, top = iconY) { - with(leftIconPainter) { - draw(size = iconDrawSize, colorFilter = ColorFilter.tint(offIconColor)) + rotate(degrees = iconRotationDegrees, pivot = iconPivot) { + with(leftIconPainter) { + draw(size = iconDrawSize, colorFilter = ColorFilter.tint(offIconColor)) + } } } @@ -347,8 +355,10 @@ private fun DrawScope.drawClippedSwitchIcons( bottom = offIconRect.bottom ) { translate(left = offIconX, top = iconY) { - with(leftIconPainter) { - draw(size = iconDrawSize, colorFilter = ColorFilter.tint(onIconColor)) + rotate(degrees = iconRotationDegrees, pivot = iconPivot) { + with(leftIconPainter) { + draw(size = iconDrawSize, colorFilter = ColorFilter.tint(onIconColor)) + } } } } @@ -360,8 +370,10 @@ private fun DrawScope.drawClippedSwitchIcons( // Draw the base (unselected) icon first translate(left = onIconX, top = iconY) { - with(rightIconPainter) { - draw(size = iconDrawSize, colorFilter = ColorFilter.tint(offIconColor)) + rotate(degrees = iconRotationDegrees, pivot = iconPivot) { + with(rightIconPainter) { + draw(size = iconDrawSize, colorFilter = ColorFilter.tint(offIconColor)) + } } } @@ -373,8 +385,10 @@ private fun DrawScope.drawClippedSwitchIcons( bottom = onIconRect.bottom ) { translate(left = onIconX, top = iconY) { - with(rightIconPainter) { - draw(size = iconDrawSize, colorFilter = ColorFilter.tint(onIconColor)) + rotate(degrees = iconRotationDegrees, pivot = iconPivot) { + with(rightIconPainter) { + draw(size = iconDrawSize, colorFilter = ColorFilter.tint(onIconColor)) + } } } } diff --git a/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/ContentGravityTest.kt b/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/ContentGravityTest.kt index 23bfd0a56f..c88c15e9dc 100644 --- a/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/ContentGravityTest.kt +++ b/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/ContentGravityTest.kt @@ -33,6 +33,16 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Canvas +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.drawscope.CanvasDrawScope +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection @@ -82,6 +92,7 @@ import com.google.jetpackcamera.ui.uistate.CustomSnackbarVisuals import com.google.jetpackcamera.ui.uistate.SingleSelectableUiState import com.google.jetpackcamera.ui.uistate.capture.AspectRatioUiState import com.google.jetpackcamera.ui.uistate.capture.AudioUiState +import com.google.jetpackcamera.ui.uistate.capture.CaptureModeToggleUiState import com.google.jetpackcamera.ui.uistate.capture.CaptureModeUiState import com.google.jetpackcamera.ui.uistate.capture.ElapsedTimeUiState import com.google.jetpackcamera.ui.uistate.capture.FlashModeUiState @@ -96,6 +107,7 @@ import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode /** * Robolectric Compose UI tests verifying content gravity rotation: @@ -894,6 +906,22 @@ class ContentGravityTest { modifier = mod ) }, + captureModeToggle = { mod -> + CaptureModeToggleButton( + uiState = CaptureModeToggleUiState.Available( + selectedCaptureMode = CaptureMode.IMAGE_ONLY, + imageOnlyUiState = SingleSelectableUiState.SelectableUi( + CaptureMode.IMAGE_ONLY + ), + videoOnlyUiState = SingleSelectableUiState.SelectableUi( + CaptureMode.VIDEO_ONLY + ) + ), + onChangeCaptureMode = {}, + onToggleWhenDisabled = {}, + modifier = mod + ) + }, elapsedTimeDisplay = { mod -> ElapsedTimeText( modifier = mod.testTag(ELAPSED_TIME_TAG), @@ -950,6 +978,71 @@ class ContentGravityTest { assertThat(label180.height.value).isWithin(TOLERANCE).of(label90.height.value) val elapsedAt180 = clippedBoundsOf(ELAPSED_TIME_TAG) assertThat(elapsedAt180.left.value).isWithin(TOLERANCE).of(elapsedAt90.left.value) + composeTestRule.onNodeWithTag(CAPTURE_MODE_TOGGLE_BUTTON).assertIsDisplayed() + } + + @GraphicsMode(GraphicsMode.Mode.NATIVE) + @Test + fun toggleSwitchIcons_rotateInPlaceAroundCenterWhileKeepingThumbClipInTrackSpace() { + // Asymmetric painter filling ONLY the top third of its 24x24 box. + val topBandPainter = object : Painter() { + private var activeColorFilter: ColorFilter? = null + override val intrinsicSize: Size = Size(24f, 24f) + override fun applyColorFilter(colorFilter: ColorFilter?): Boolean { + activeColorFilter = colorFilter + return true + } + override fun DrawScope.onDraw() { + drawRect( + color = Color.White, + topLeft = Offset.Zero, + size = Size(size.width, size.height / 3f), + colorFilter = activeColorFilter + ) + } + } + + fun renderLeftIconAt(rotationDegrees: Float): android.graphics.Bitmap { + val bitmap = createBitmap(60, 24) + val composeCanvas = Canvas(bitmap.asImageBitmap()) + CanvasDrawScope().draw( + density = Density(1f), + layoutDirection = LayoutDirection.Ltr, + canvas = composeCanvas, + size = Size(60f, 24f) + ) { + // Thumb covers the left half [0, 12] of the [0, 24] left icon. + drawClippedSwitchIcons( + thumbRect = Rect(0f, 0f, 12f, 24f), + iconSizePx = 24f, + iconPadding = 0f, + leftIconPainter = topBandPainter, + rightIconPainter = null, + offIconColor = Color.Red, + onIconColor = Color.Green, + iconRotationDegrees = rotationDegrees + ) + } + return bitmap + } + + // At 0°: top band is at y=4 across x=[0..23], clipped Green on x=4 and Red on x=20. + val bmp0 = renderLeftIconAt(0f) + assertThat(bmp0.getPixel(4, 4)).isEqualTo(android.graphics.Color.GREEN) + assertThat(bmp0.getPixel(20, 4)).isEqualTo(android.graphics.Color.RED) + assertThat(bmp0.getPixel(12, 20)).isEqualTo(android.graphics.Color.TRANSPARENT) + + // At +90° CW: top band rotates to the right band x=[16..23], y=[0..23] (outside thumb + // [0..12], so Red at (20, 12) and transparent at (4, 12)). + val bmp90 = renderLeftIconAt(90f) + assertThat(bmp90.getPixel(20, 12)).isEqualTo(android.graphics.Color.RED) + assertThat(bmp90.getPixel(4, 12)).isEqualTo(android.graphics.Color.TRANSPARENT) + + // At 270° (-90° CCW): top band rotates to the left band x=[0..7], y=[0..23] (inside thumb + // [0..12], so Green at (4, 12) and transparent at (20, 12)). + val bmp270 = renderLeftIconAt(270f) + assertThat(bmp270.getPixel(4, 12)).isEqualTo(android.graphics.Color.GREEN) + assertThat(bmp270.getPixel(20, 12)).isEqualTo(android.graphics.Color.TRANSPARENT) } private companion object {