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 1b55c13d0..2ee61e1d6 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 2f6b686e5..52465c3fc 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/CameraErrorDialog.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CameraErrorDialog.kt index eeedec2c5..8db225ae9 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 @@ -85,6 +85,7 @@ fun CameraErrorDialog( ) { Surface( modifier = Modifier + .rotateLayoutToGravity() .padding(horizontal = 24.dp) .widthIn(min = 280.dp, max = 560.dp) .pointerInput(Unit) { 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 e6c317987..4831579fe 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 b58cc9162..bf8a43264 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 @@ -202,8 +202,18 @@ internal fun CaptureScaffoldLayout( heightDp = maxHeight, isFullScreenCompactPhone = isFullScreenCompactPhone ) + val isCompactPortraitBacked = layoutMode == CameraLayoutMode.COMPACT || + layoutMode == CameraLayoutMode.COMPACT_SLIM + val outerGravityRotationState = if (isCompactPortraitBacked) { + LocalGravityRotationState.current + } else { + DefaultGravityRotationState + } - CompositionLocalProvider(LocalCameraLayoutMode provides layoutMode) { + CompositionLocalProvider( + LocalCameraLayoutMode provides layoutMode, + LocalGravityRotationState provides outerGravityRotationState + ) { val resolvedSlots = slots ?: CaptureSlots( topBar = indicatorRow, topBarTrailingContent = topBarTrailingContent, @@ -223,7 +233,6 @@ internal fun CaptureScaffoldLayout( snackBar = snackBar, errorDialog = errorDialog ) - when (layoutMode) { CameraLayoutMode.COMPACT, CameraLayoutMode.COMPACT_SLIM -> { 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 84ee023ea..6e28477e6 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 + ) } } 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 5a3ef9906..e1e6bef15 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 @@ -222,7 +222,9 @@ fun CameraControlIconButton( val backgroundStyle = LocalCameraControlBackgroundStyle.current FilledIconButton( onClick = onClick, - modifier = modifier.size(CameraControlIconButtonSize), + modifier = modifier + .size(CameraControlIconButtonSize) + .animateToUpright(), enabled = enabled, shape = CircleShape, colors = IconButtonDefaults.filledIconButtonColors( @@ -546,7 +548,9 @@ fun PauseResumeToggleButton( currentRecordingState !is VideoRecordingState.Active.Paused ) }, - modifier = modifier.size(size) + modifier = modifier + .size(size) + .animateToUpright() ) { Icon( modifier = Modifier @@ -602,6 +606,7 @@ fun AmplitudeToggleButton( FilledIconToggleButton( modifier = modifier .size(buttonSize) + .animateToUpright() .testTag(AUDIO_INPUT_TOGGLE) .semantics { stateDescription = if (audioUiState is AudioUiState.Enabled.On) { @@ -1310,7 +1315,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) { @@ -1395,7 +1402,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) @@ -1589,6 +1598,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 6378a25d4..a2bf3d40a 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 @@ -49,6 +55,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.LocalMinimumInteractiveComponentSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SheetValue +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.darkColorScheme import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.material3.rememberBottomSheetScaffoldState @@ -65,11 +72,17 @@ 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.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.layout.AlignmentLine import androidx.compose.ui.layout.Layout import androidx.compose.ui.layout.Placeable @@ -87,6 +100,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.max import androidx.compose.ui.unit.min @@ -99,6 +113,7 @@ import com.google.jetpackcamera.ui.components.capture.layout.CameraLayoutSpec 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 /** * CompositionLocal providing the exact active posture layout composable name rendered by @@ -117,6 +132,21 @@ internal 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. @@ -289,18 +319,31 @@ internal 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 = state.isQuickSettingsOpen || + bottomSheetState.isVisible || + bottomSheetState.targetValue == SheetValue.Expanded val handleDismissQuickSettings = { state.dismissQuickSettings() onDismissQuickSettings() } + val consumeRemainingScrollConnection = remember { + object : NestedScrollConnection { + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource + ): Offset = available + + override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity = + available + } + } SeamlessRotationAnimationEffect() - val isSheetVisible = state.isQuickSettingsOpen || - bottomSheetState.isVisible || - bottomSheetState.targetValue == SheetValue.Expanded var isErrorDialogPlaced by remember { mutableStateOf(false) } val isErrorModalActive = isErrorDialogShowing || isErrorDialogPlaced val isModalOpen = isSheetVisible || isErrorModalActive @@ -324,7 +367,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 @@ -344,6 +390,50 @@ internal fun CompactPortraitLayout( deviceRotation = effectiveDeviceRotation, windowRotation = windowRotation ) + val targetGravityDegrees = rememberStickyGravityTargetDegrees( + deviceRotation = effectiveDeviceRotation, + windowRotation = 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()) val portraitWidth = if (shouldCounterRotate) windowHeight else windowWidth val portraitHeight = if (shouldCounterRotate) windowWidth else windowHeight @@ -458,12 +548,61 @@ internal fun CompactPortraitLayout( ), sheetContainerColor = MaterialTheme.colorScheme.surfaceContainerLow, sheetContent = { - CompositionLocalProvider(LocalLayoutDirection provides localeDirection) { - OverlapAwareCaptureSlot( - orientation = Orientation.Vertical, - isCompact = false, - content = slots.quickSettingsOverlay - ) + 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 + ) + .nestedScroll(consumeRemainingScrollConnection) + .rotateLayoutToGravity(displayedSheetGravityDegrees), + contentAlignment = Alignment.TopStart + ) { + OverlapAwareCaptureSlot( + orientation = Orientation.Vertical, + isCompact = false, + content = slots.quickSettingsOverlay + ) + } + } else { + OverlapAwareCaptureSlot( + orientation = Orientation.Vertical, + isCompact = false, + content = slots.quickSettingsOverlay + ) + } + } } }, snackbarHost = {} @@ -477,6 +616,24 @@ internal fun CompactPortraitLayout( modifier = proxyMod ) } + val bottomElapsedTimeDisplay: + @Composable PreviewOverlaySlotScope.(Modifier) -> Unit = + if (isLandscapeGravity) { + {} + } else { + elapsedTimeDisplay + } + val bottomSnackbarHost: @Composable (Modifier) -> Unit = + if (isLandscapeGravity) { + {} + } else { + { hostModifier -> + PillSnackbarHost( + snackbarHostState = state.snackbarHostState, + modifier = hostModifier + ) + } + } if (isHandheldPortrait) { AdaptivePortraitCaptureLayout( windowWidth = maxWidth, @@ -492,7 +649,7 @@ internal fun CompactPortraitLayout( onRequiresImmersiveChange = onRequiresImmersiveChange, indicatorRow = indicatorRow, topBarTrailingContent = topBarTrailingContent, - elapsedTimeDisplay = elapsedTimeDisplay, + elapsedTimeDisplay = bottomElapsedTimeDisplay, zoomControls = zoomLevelDisplay, imageWell = imageWell, captureButton = captureButton, @@ -500,12 +657,7 @@ internal fun CompactPortraitLayout( captureModeCarousel = captureModeCarousel, quickSettingsToggleButton = quickSettingsButton, captureModeToggleSwitch = captureModeToggle, - snackbarHost = { hostModifier -> - PillSnackbarHost( - snackbarHostState = state.snackbarHostState, - modifier = hostModifier - ) - } + snackbarHost = bottomSnackbarHost ) Box( modifier = Modifier @@ -517,6 +669,14 @@ internal fun CompactPortraitLayout( .toPortraitWindowInsets(effectiveWindowRotation) ) ) { + if (isLandscapeGravity && controlsVisible) { + LandscapeTopEdgeBanner( + targetGravityDegrees = targetGravityDegrees, + localeDirection = localeDirection, + snackbarHostState = state.snackbarHostState, + elapsedTimeDisplay = elapsedTimeDisplay + ) + } CompositionLocalProvider( LocalLayoutDirection provides localeDirection ) { @@ -563,13 +723,16 @@ internal fun CompactPortraitLayout( captureModeToggleSwitch = captureModeToggle, captureModeCarousel = captureModeCarousel, zoomControls = zoomLevelDisplay, - elapsedTimeDisplay = elapsedTimeDisplay, - snackbarHost = { hostModifier -> - PillSnackbarHost( - snackbarHostState = state.snackbarHostState, - modifier = hostModifier - ) - } + elapsedTimeDisplay = bottomElapsedTimeDisplay, + snackbarHost = bottomSnackbarHost + ) + } + if (isLandscapeGravity && controlsVisible) { + LandscapeTopEdgeBanner( + targetGravityDegrees = targetGravityDegrees, + localeDirection = localeDirection, + snackbarHostState = state.snackbarHostState, + elapsedTimeDisplay = elapsedTimeDisplay ) } CompositionLocalProvider( @@ -763,6 +926,34 @@ private fun ExternalViewfinderProxy( } } +@Composable +private fun BoxScope.LandscapeTopEdgeBanner( + targetGravityDegrees: Int, + localeDirection: LayoutDirection, + snackbarHostState: SnackbarHostState, + elapsedTimeDisplay: @Composable PreviewOverlaySlotScope.(Modifier) -> Unit +) { + val landscapeTopAlignment = + if (targetGravityDegrees == CompactPortraitTokens.LANDSCAPE_CCW_GRAVITY_DEGREES) { + AbsoluteAlignment.CenterRight + } else { + AbsoluteAlignment.CenterLeft + } + CompositionLocalProvider(LocalLayoutDirection provides localeDirection) { + Column( + modifier = Modifier + .align(landscapeTopAlignment) + .rotateLayoutToGravity(targetGravityDegrees), + horizontalAlignment = Alignment.CenterHorizontally + ) { + PillSnackbarHost(snackbarHostState = snackbarHostState) + OverlapAwareStyleProvider(orientation = Orientation.Horizontal) { + elapsedTimeDisplay(Modifier) + } + } + } +} + @Composable private fun computeTopBarMetrics( includeStatusBarsInTopBar: Boolean, 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 2f1a48a6f..16028ddbb 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 cbc2cdcf2..65b83df28 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,8 +37,10 @@ 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.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState @@ -59,8 +61,17 @@ import androidx.lifecycle.compose.LifecycleStartEffect import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.repeatOnLifecycle import com.google.jetpackcamera.model.DeviceRotation +import kotlin.math.roundToInt private const val UI_ROTATION_DURATION_MS = 250 +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) /** @@ -162,11 +173,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°`) across `0°` upright (`-90f <-> +90f`) rather than `180°`. * - Animates `90°` quarter-turns smoothly by default ([animateQuarterTurns] = `true`), while * snapping immediately on a `180°` half-turn flip (`90° <-> 270°`), when [windowRotation] changes * (since the OS is already animating the window rotation), when [LocalDisableAnimations] is @@ -178,7 +218,10 @@ fun rememberGravityRotationState( windowRotation: DeviceRotation = LocalWindowRotation.current, animateQuarterTurns: Boolean = true ): 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) } @@ -190,7 +233,11 @@ fun rememberGravityRotationState( val currentMod = ((continuousTarget % 360f) + 360f) % 360f val rawDelta = ((targetClockwiseDegrees - currentMod + 540f) % 360f) - 180f val isHalfTurnFlip = rawDelta == -180f || rawDelta == 180f - val delta = if (rawDelta == -180f) 180f else rawDelta + val delta = if (isHalfTurnFlip) { + if (currentMod >= 180f) 180f else -180f + } else { + rawDelta + } var snapCurrentTransition by remember { mutableStateOf(false) } if (delta != 0f || windowRotationChanged) { snapCurrentTransition = windowRotationChanged || isHalfTurnFlip @@ -226,6 +273,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 @@ -233,36 +308,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/SideRailCaptureLayouts.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/SideRailCaptureLayouts.kt index efbdb1535..1c05db896 100644 --- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/SideRailCaptureLayouts.kt +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/SideRailCaptureLayouts.kt @@ -37,9 +37,7 @@ import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.union import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.darkColorScheme @@ -585,9 +583,7 @@ private fun QuickSettingsOverlayCardPlaceholder( tonalElevation = SideRailTokens.QuickSettingsCardTonalElevation ) { CompositionLocalProvider(LocalLayoutDirection provides localeDirection) { - Box(modifier = Modifier.verticalScroll(rememberScrollState())) { - content(Modifier) - } + content(Modifier) } } } 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 6244c8e36..c22fd035a 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/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 8d1aecf06..21a887806 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/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 35e4f3066..c0730c02a 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.LocalWindowRotation import com.google.jetpackcamera.ui.components.capture.QUICK_SETTINGS_BOTTOM_SHEET import com.google.jetpackcamera.ui.components.capture.R @@ -45,6 +48,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.components.capture.toPortraitWindowInsets import com.google.jetpackcamera.ui.controller.quicksettings.QuickSettingsController import com.google.jetpackcamera.ui.uistate.SingleSelectableUiState @@ -74,13 +78,19 @@ fun QuickSettingsScaffoldContent( showMoreSettingsButton: Boolean = true ) { if (quickSettingsUiState is QuickSettingsUiState.Available) { + val gravityDegrees = snapToQuadrantDegrees(LocalGravityRotationState.current.value) + val isLandscapeSheet = gravityDegrees == 90 || gravityDegrees == 270 val portraitNavBars = WindowInsets.navigationBarsIgnoringVisibility .toPortraitWindowInsets(LocalWindowRotation.current) Column( modifier = modifier .fillMaxWidth() + .verticalScroll(rememberScrollState()) .windowInsetsPadding(portraitNavBars) - .padding(bottom = 24.dp) + .padding( + top = if (isLandscapeSheet) 24.dp else 0.dp, + bottom = 24.dp + ) .testTag(QUICK_SETTINGS_BOTTOM_SHEET) ) { QuickSettingsContent( 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 0f67ac377..d27fd928d 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 000000000..c88c15e9d --- /dev/null +++ b/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/ContentGravityTest.kt @@ -0,0 +1,1051 @@ +/* + * 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.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +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.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 +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.onNodeWithText +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.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.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 +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 +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: + * - In-place glyph rotation (`Modifier.animateToUpright()`) on `1:1` controls and indicator icons. + * - Single (`90°`, non-double-rotated) upright rotation for [CameraControlIconButton] placed in + * `topStartContent` of [CompactPortraitLayout]. + * - Swapped-constraint rectangular block rotation (`Modifier.rotateLayoutToGravity()`) on + * in-hierarchy [CameraErrorDialog], landscape `PillSnackbarHost` + `ElapsedTimeText` top-edge + * banner, and compact [CaptureScaffoldDefaults.QuickSettings] bottom sheet. + * - Zero (`0°`) gravity rotation enforcement in [FoldableLayout], [TabletPortraitLayout], + * [TabletLandscapeLayout], and [CompactShortLayout]. + */ +@RunWith(RobolectricTestRunner::class) +@Config(qualifiers = "w1600dp-h1400dp-mdpi") +class ContentGravityTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private fun unclippedBoundsOf(tag: String): DpRect = + composeTestRule.onNodeWithTag(tag).getUnclippedBoundsInRoot() + + private fun clippedBoundsOf(tag: String, useUnmergedTree: Boolean = false): DpRect = + composeTestRule.onNodeWithTag(tag, useUnmergedTree = useUnmergedTree).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" + val controlIconTag = "slot_control_icon" + + 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["controlIcon"] = it }) { + CameraControlIconButton( + onClick = {}, + modifier = Modifier.testTag(controlIconTag) + ) { + Box(Modifier.size(20.dp)) + } + } + } + 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, + controlIconTag + ) + 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 topStartCameraControlIconButton_inCompactPortraitLandscape_rotates90DegreesNot180() { + val buttonTag = "top_start_control_btn" + val topMarkerTag = "top_start_control_marker" + var deviceRotation by mutableStateOf(DeviceRotation.Natural) + + composeTestRule.setContent { + MaterialTheme { + CompositionLocalProvider( + LocalDensity provides Density(1f), + LocalDisableAnimations provides true + ) { + Box(modifier = Modifier.size(width = 412.dp, height = 915.dp)) { + CompactPortraitLayout( + slots = CaptureSlots( + topStartContent = { mod -> + CameraControlIconButton( + onClick = {}, + modifier = mod.testTag(buttonTag) + ) { + Box(Modifier.fillMaxSize()) { + Box( + Modifier + .align(Alignment.TopCenter) + .size(width = 16.dp, height = 4.dp) + .testTag(topMarkerTag) + ) + } + } + } + ), + viewfinder = { mod -> Box(mod) }, + deviceRotation = deviceRotation, + windowRotation = DeviceRotation.Natural + ) + } + } + } + } + composeTestRule.waitForIdle() + + val portraitBtn = clippedBoundsOf(buttonTag) + val portraitMarker = clippedBoundsOf(topMarkerTag, useUnmergedTree = true) + val portraitBtnCenterY = (portraitBtn.top.value + portraitBtn.bottom.value) / 2f + val portraitMarkerCenterY = (portraitMarker.top.value + portraitMarker.bottom.value) / 2f + assertThat(portraitMarkerCenterY).isLessThan(portraitBtnCenterY - 4f) + + // Rotated270 -> +90° CW gravity: top-aligned marker rotates once (+90° CW) to the right + // edge of CameraControlIconButton, NOT twice (+180°) to the bottom edge. + deviceRotation = DeviceRotation.Rotated270 + composeTestRule.waitForIdle() + + val landscapeBtn = clippedBoundsOf(buttonTag) + val landscapeMarker = clippedBoundsOf(topMarkerTag, useUnmergedTree = true) + val landscapeBtnCenterX = (landscapeBtn.left.value + landscapeBtn.right.value) / 2f + val landscapeBtnCenterY = (landscapeBtn.top.value + landscapeBtn.bottom.value) / 2f + val landscapeMarkerCenterX = (landscapeMarker.left.value + landscapeMarker.right.value) / 2f + val landscapeMarkerCenterY = (landscapeMarker.top.value + landscapeMarker.bottom.value) / 2f + assertThat(landscapeMarkerCenterX).isGreaterThan(landscapeBtnCenterX + 4f) + assertThat(landscapeMarkerCenterY).isWithin(TOLERANCE).of(landscapeBtnCenterY) + } + + @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 state = rememberCaptureScaffoldState() + LaunchedEffect(state.snackbarHostState) { + state.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( + slots = CaptureSlots( + topBar = { mod -> Box(mod.size(width = 120.dp, height = 36.dp)) }, + zoomControls = { mod -> + Box(mod.size(width = 160.dp, height = 48.dp)) + }, + quickSettingsButton = { mod -> Box(mod.size(48.dp)) }, + captureButton = { mod -> Box(mod.size(80.dp)) }, + flipCameraButton = { mod -> Box(mod.size(48.dp)) }, + imageWell = { mod -> Box(mod.size(48.dp)) }, + captureModeToggle = { mod -> + Box(mod.size(width = 96.dp, height = 48.dp)) + }, + elapsedTimeDisplay = { mod -> + ElapsedTimeText( + modifier = mod, + elapsedTimeUiStateProvider = { + ElapsedTimeUiState.Enabled( + elapsedTimeNanos = 15_000_000_000L + ) + } + ) + } + ), + viewfinder = { mod -> Box(mod) }, + state = state, + deviceRotation = activeRotation, + windowRotation = activeRotation + ) + } + } + } + } + + 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 state = remember { CaptureScaffoldState(isInitiallyExpanded = true) } + MaterialTheme { + CompositionLocalProvider( + LocalDensity provides Density(1f), + LocalDisableAnimations provides true + ) { + Box(modifier = Modifier.size(width = 412.dp, height = 915.dp)) { + CompactPortraitLayout( + slots = CaptureSlots( + topBar = { mod -> Box(mod.size(width = 120.dp, height = 36.dp)) }, + zoomControls = { mod -> + Box(mod.size(width = 160.dp, height = 48.dp)) + }, + quickSettingsButton = { mod -> Box(mod.size(48.dp)) }, + captureButton = { mod -> Box(mod.size(80.dp)) }, + flipCameraButton = { mod -> Box(mod.size(48.dp)) }, + imageWell = { mod -> Box(mod.size(48.dp)) }, + captureModeToggle = { mod -> + Box(mod.size(width = 96.dp, height = 48.dp)) + }, + quickSettingsOverlay = { mod -> + CaptureScaffoldDefaults.QuickSettings( + modifier = mod, + uiState = sampleQuickSettingsUiState(), + controller = fakeController, + onNavigateToSettings = {} + ) + } + ), + viewfinder = { mod -> Box(mod) }, + state = state, + deviceRotation = deviceRotation, + windowRotation = DeviceRotation.Natural + ) + } + } + } + } + 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, while rotated bounds in root fit within 412x380dp. + 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(landscapeSheetBounds.width.value).isAtMost(412f + TOLERANCE) + assertThat(landscapeSheetBounds.height.value).isAtMost(380f + TOLERANCE) + + // Flash row inside +90° rotated sheet runs vertically along the portrait root (upright for + // the landscape user) and remains clickable. + val flashRowBounds = clippedBoundsOf(ROW_QUICK_SETTINGS_FLASH) + assertThat(flashRowBounds.height.value).isGreaterThan(flashRowBounds.width.value) + composeTestRule.onNodeWithTag(BTN_QUICK_SETTINGS_FLASH_OPTION_ON).performClick() + assertThat(selectedFlash).isEqualTo(FlashMode.ON) + } + + @Test + fun sideRailAndCompactShortLayouts_inLandscape_keepZeroGravityRotation() { + val postures = listOf( + "FoldableLayout" to DpSize(852.dp, 883.dp), + "TabletPortraitLayout" to DpSize(800.dp, 1280.dp), + "TabletLandscapeLayout" to DpSize(1280.dp, 800.dp), + "CompactShortLayout" to DpSize(360.dp, 360.dp) + ) + var activePostureIndex by mutableStateOf(0) + var showErrorDialog by mutableStateOf(true) + var observedRailGravity: Float? = null + val fakeController = FakeQuickSettingsController() + val state = CaptureScaffoldState(isInitiallyExpanded = false) + + @Composable + fun buildRailSlots(isCompactShort: Boolean): CaptureSlots = PlaceholderCaptureSlots + .sideRail( + isCompactHeight = isCompactShort, + includeElapsedTime = true + ) + .copy( + quickSettingsOverlay = { mod -> + CaptureScaffoldDefaults.QuickSettings( + modifier = mod, + uiState = sampleQuickSettingsUiState(), + controller = fakeController, + onNavigateToSettings = {} + ) + }, + errorDialog = { mod -> + if (showErrorDialog) { + CameraErrorDialog( + modifier = mod, + title = "Camera error", + body = "Try again", + confirmButtonText = "OK", + onConfirm = {} + ) + } + } + ) + + composeTestRule.setContent { + val (layoutName, containerSize) = postures[activePostureIndex] + 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(layoutName == "CompactShortLayout") + val vf: @Composable (Modifier) -> Unit = { mod -> + observedRailGravity = LocalGravityRotationState.current.value + PlaceholderViewfinder(modifier = mod) + } + when (layoutName) { + "FoldableLayout" -> FoldableLayout( + slots = slots, + viewfinder = vf, + state = state, + isErrorDialogShowing = showErrorDialog + ) + "TabletPortraitLayout" -> TabletPortraitLayout( + slots = slots, + viewfinder = vf, + state = state, + isErrorDialogShowing = showErrorDialog + ) + "TabletLandscapeLayout" -> TabletLandscapeLayout( + slots = slots, + viewfinder = vf, + state = state, + isErrorDialogShowing = showErrorDialog + ) + else -> CompactShortLayout( + slots = slots, + viewfinder = vf, + state = state, + isErrorDialogShowing = showErrorDialog + ) + } + } + } + } + } + + postures.forEachIndexed { index, (layoutName, _) -> + activePostureIndex = index + showErrorDialog = true + composeTestRule.runOnIdle { state.dismissQuickSettings() } + composeTestRule.waitForIdle() + + // Even with ambient 90f gravity and Rotated270 device/window rotations, all rail + // layouts enforce 0f gravity rotation. + assertWithMessage("$layoutName gravity").that(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.runOnIdle { state.showQuickSettings() } + composeTestRule.waitForIdle() + + composeTestRule + .onNodeWithTag(QUICK_SETTINGS_OVERLAY_CARD_PLACEHOLDER_TAG) + .assertIsDisplayed() + 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 observedActiveLayout: String? = null + var observedInteriorGravity: Float? = null + + composeTestRule.setContent { + MaterialTheme { + CompositionLocalProvider( + LocalDensity provides Density(1f), + LocalDisableAnimations provides true + ) { + CaptureScaffoldLayout( + modifier = Modifier.size(width = 800.dp, height = 400.dp), + deviceRotation = DeviceRotation.Natural, + windowRotation = DeviceRotation.Natural, + viewfinder = { mod -> + observedMode = LocalCameraLayoutMode.current + observedActiveLayout = LocalActiveCaptureLayout.current + PlaceholderViewfinder(modifier = mod) + }, + captureButton = { mod -> + observedInteriorGravity = LocalGravityRotationState.current.value + 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 = {}, + overlay = {}, + controlsVisible = true, + screenFlashOverlay = {}, + snackBar = { _, _ -> }, + errorDialog = { mod -> + CameraErrorDialog( + modifier = mod, + title = "Camera error", + body = "Try again", + confirmButtonText = "OK", + onConfirm = {} + ) + } + ) + } + } + } + composeTestRule.waitForIdle() + + assertThat(observedMode).isEqualTo(CameraLayoutMode.COMPACT) + assertThat(observedActiveLayout).isEqualTo("CompactPortraitLayout") + 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) + } + + @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 + ) + }, + 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), + 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) + 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 { + 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 36aace208..47d3e55b0 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 @@ -27,10 +27,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshots.Snapshot -<<<<<<< HEAD import androidx.compose.ui.Alignment -======= ->>>>>>> kim/adaptive/debug-layout-readout import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Rect import androidx.compose.ui.layout.onSizeChanged @@ -90,6 +87,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 rememberGravityRotationState_animatesQuarterTurnsByDefaultAndSnapsOn180Flip() { var deviceRotation by mutableStateOf(DeviceRotation.Natural)