From 9225de43fbef7f3548f7963f59bbb14eef8fdfe6 Mon Sep 17 00:00:00 2001 From: Kimberly Crevecoeur Date: Fri, 6 Nov 2026 05:38:46 -0800 Subject: [PATCH 1/3] feat(ui/capture, debug): add 6-mode CameraLayoutMode orchestrator and debug readout --- .../feature/preview/PreviewScreen.kt | 4 +- .../ui/components/capture/CaptureLayout.kt | 393 ++++++++++----- .../capture/layout/CameraLayoutMode.kt | 324 ++++++++++++ .../capture/AdaptiveCaptureLayoutTest.kt | 461 ++++++++++++++++++ .../capture/layout/CameraLayoutModeTest.kt | 317 ++++++++++++ .../ui/debug/DebugOverlayComponents.kt | 16 +- .../google/jetpackcamera/ui/debug/TestTags.kt | 1 + ui/debug/src/main/res/values/strings.xml | 1 + 8 files changed, 1402 insertions(+), 115 deletions(-) create mode 100644 ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/layout/CameraLayoutMode.kt create mode 100644 ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/AdaptiveCaptureLayoutTest.kt create mode 100644 ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/layout/CameraLayoutModeTest.kt diff --git a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt index aacff43a8a..dd80fe1607 100644 --- a/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt +++ b/feature/preview/src/main/java/com/google/jetpackcamera/feature/preview/PreviewScreen.kt @@ -94,6 +94,7 @@ import com.google.jetpackcamera.ui.components.capture.VideoQualityIcon import com.google.jetpackcamera.ui.components.capture.ZoomBar import com.google.jetpackcamera.ui.components.capture.ZoomStateManager import com.google.jetpackcamera.ui.components.capture.debouncedOrientationFlow +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 @@ -705,7 +706,8 @@ internal fun ContentScreen( scope.launch { zoomStateManager.absoluteZoom(f, LensToZoom.PRIMARY) } }, extraControls = extraControls.orEmpty(), - debugController = debugController + debugController = debugController, + cameraLayoutMode = LocalCameraLayoutMode.current.name ) } } 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 624f1caabf..e36e51bbc9 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 @@ -17,18 +17,30 @@ package com.google.jetpackcamera.ui.components.capture import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size import androidx.compose.material3.BottomSheetScaffoldState import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SheetValue import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.movableContentOf +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.HorizontalAlignmentLine +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +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.layout.rememberCameraLayoutMode +import com.google.jetpackcamera.ui.components.capture.layout.rememberIsFullScreenCompactPhone private object PreviewLayoutTokens { const val PREVIEW_VIEWFINDER_ASPECT_RATIO = 3f / 4f @@ -36,6 +48,8 @@ private object PreviewLayoutTokens { val PreviewControlSize = 48.dp val PreviewElapsedHeight = 24.dp const val PREVIEW_HALF_WIDTH_FRACTION = 0.5f + val PreviewWideShortWidth = 800.dp + val PreviewWideShortHeight = 400.dp } /** @@ -49,11 +63,18 @@ private object PreviewLayoutTokens { internal val ViewfinderTopLine = HorizontalAlignmentLine { a, b -> minOf(a, b) } /** - * The base layout for the camera capture screen. + * Adaptive orchestrator layout for the camera capture screen. + * + * Evaluates the available container geometry via [rememberCameraLayoutMode] with a `14.dp` + * (`±7.dp`) hysteresis deadband around `600.dp` width and `480.dp` height, provides the resolved + * [CameraLayoutMode] via [LocalCameraLayoutMode], wraps [viewfinder] in [movableContentOf] so the + * live camera surface is reparented across layout transitions without disposal, and delegates to + * [CompactPortraitLayout], [TwoSideRailLayout], or [WideShortLayout]. * * @param modifier the modifier for this component * @param scaffoldState the bottom sheet scaffold state * @param onDismissQuickSettings callback to dismiss quick settings when clicking the drag handle + * or scrim * @param viewfinder the viewfinder composable * @param captureButton the capture button composable * @param imageWell the image well composable @@ -95,6 +116,9 @@ internal val ViewfinderTopLine = HorizontalAlignmentLine { a, b -> minOf(a, b) } * [CameraSystemBarsPolicy.HideStatusAndNavigationBars] to [CameraSystemBarsEffect]. This layout * does not change the system bars itself. * @param errorDialog the camera error alert dialog composable + * @param slots optional [CaptureSlots] slot table for adaptive layouts; when `null`, + * [CameraLayoutMode.COMPACT_PORTRAIT] uses the individual control composables and side-rail + * modes fall back to [PlaceholderCaptureSlots] bridged with [snackBar] and [errorDialog]. */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -121,122 +145,269 @@ fun PreviewLayout( viewfinderTopEndContent: @Composable PreviewOverlaySlotScope.(Modifier) -> Unit = {}, topBarTrailingContent: @Composable (Modifier) -> Unit = {}, onRequiresImmersiveChange: (Boolean) -> Unit = {}, - errorDialog: @Composable (Modifier) -> Unit = {} + errorDialog: @Composable (Modifier) -> Unit = {}, + slots: CaptureSlots? = null ) { - CompactPortraitLayout( - modifier = modifier, - scaffoldState = scaffoldState, - onDismissQuickSettings = onDismissQuickSettings, - includeStatusBarsInTopBar = false, - viewfinder = viewfinder, - captureButton = captureButton, - imageWell = imageWell, - flipCameraButton = flipCameraButton, - zoomLevelDisplay = zoomLevelDisplay, - elapsedTimeDisplay = elapsedTimeDisplay, - quickSettingsButton = quickSettingsButton, - indicatorRow = indicatorRow, - captureModeToggle = captureModeToggle, - captureModeCarousel = captureModeCarousel, - quickSettingsOverlay = quickSettingsOverlay, - debugOverlay = debugOverlay, - debugVisibilityWrapper = debugVisibilityWrapper, - screenFlashOverlay = screenFlashOverlay, - snackBar = snackBar, - topStartContent = topStartContent, - viewfinderTopEndContent = viewfinderTopEndContent, - topBarTrailingContent = topBarTrailingContent, - onRequiresImmersiveChange = onRequiresImmersiveChange, - errorDialog = errorDialog - ) + val movableViewfinder = remember(viewfinder) { + movableContentOf { vfModifier -> + viewfinder(vfModifier) + } + } + + BoxWithConstraints( + modifier = modifier + .fillMaxSize() + .background(Color.Black) + ) { + val isFullScreenCompactPhone = rememberIsFullScreenCompactPhone(maxWidth, maxHeight) + val layoutMode = rememberCameraLayoutMode( + widthDp = maxWidth, + heightDp = maxHeight, + isFullScreenCompactPhone = isFullScreenCompactPhone + ) + val isQuickSettingsOpen = scaffoldState.bottomSheetState.isVisible || + scaffoldState.bottomSheetState.targetValue == SheetValue.Expanded + + CompositionLocalProvider(LocalCameraLayoutMode provides layoutMode) { + when (layoutMode) { + CameraLayoutMode.COMPACT_PORTRAIT, + CameraLayoutMode.COMPACT_HORIZONTAL, + CameraLayoutMode.COMPACT_SLIM -> { + if (slots != null) { + CompactPortraitLayout( + slots = slots, + viewfinder = movableViewfinder, + scaffoldState = scaffoldState, + includeStatusBarsInTopBar = !isFullScreenCompactPhone, + onDismissQuickSettings = onDismissQuickSettings, + onRequiresImmersiveChange = onRequiresImmersiveChange, + debugOverlay = debugOverlay, + debugVisibilityWrapper = debugVisibilityWrapper, + screenFlashOverlay = screenFlashOverlay + ) + } else { + CompactPortraitLayout( + scaffoldState = scaffoldState, + onDismissQuickSettings = onDismissQuickSettings, + includeStatusBarsInTopBar = !isFullScreenCompactPhone, + viewfinder = movableViewfinder, + captureButton = captureButton, + imageWell = imageWell, + flipCameraButton = flipCameraButton, + zoomLevelDisplay = zoomLevelDisplay, + elapsedTimeDisplay = elapsedTimeDisplay, + quickSettingsButton = quickSettingsButton, + indicatorRow = indicatorRow, + captureModeToggle = captureModeToggle, + captureModeCarousel = captureModeCarousel, + quickSettingsOverlay = quickSettingsOverlay, + debugOverlay = debugOverlay, + debugVisibilityWrapper = debugVisibilityWrapper, + screenFlashOverlay = screenFlashOverlay, + snackBar = snackBar, + topStartContent = topStartContent, + viewfinderTopEndContent = viewfinderTopEndContent, + topBarTrailingContent = topBarTrailingContent, + onRequiresImmersiveChange = onRequiresImmersiveChange, + errorDialog = errorDialog + ) + } + } + + CameraLayoutMode.TWO_SIDE_RAIL -> { + val effectiveSlots = slots ?: remember(snackBar, errorDialog) { + PlaceholderCaptureSlots.default().copy( + snackBar = snackBar, + errorDialog = errorDialog + ) + } + TwoSideRailLayout( + slots = effectiveSlots, + viewfinder = movableViewfinder, + snackbarHostState = scaffoldState.snackbarHostState, + isQuickSettingsOpen = isQuickSettingsOpen, + onDismissQuickSettings = onDismissQuickSettings, + debugOverlay = debugOverlay, + debugVisibilityWrapper = debugVisibilityWrapper, + screenFlashOverlay = screenFlashOverlay + ) + } + + CameraLayoutMode.COMPACT_SHORT, + CameraLayoutMode.COMPACT_SHORT_SLIM -> { + val effectiveSlots = slots ?: remember(snackBar, errorDialog) { + PlaceholderCaptureSlots.sideRail(isCompactHeight = true).copy( + snackBar = snackBar, + errorDialog = errorDialog + ) + } + WideShortLayout( + slots = effectiveSlots, + viewfinder = movableViewfinder, + snackbarHostState = scaffoldState.snackbarHostState, + isQuickSettingsOpen = isQuickSettingsOpen, + onDismissQuickSettings = onDismissQuickSettings, + debugOverlay = debugOverlay, + debugVisibilityWrapper = debugVisibilityWrapper, + screenFlashOverlay = screenFlashOverlay + ) + } + } + } + } } @OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun AdaptivePreviewLayoutSample( + modifier: Modifier = Modifier, + slots: CaptureSlots? = null +) { + MaterialTheme { + PreviewLayout( + modifier = modifier.background(Color.Black), + viewfinder = { vfModifier -> + if (slots != null) { + PlaceholderViewfinder(modifier = vfModifier) + } else { + Box( + modifier = vfModifier + .fillMaxWidth() + .aspectRatio(PreviewLayoutTokens.PREVIEW_VIEWFINDER_ASPECT_RATIO) + .background(Color.DarkGray) + ) + } + }, + captureButton = { btnModifier -> + Box( + modifier = btnModifier + .size(PreviewLayoutTokens.PreviewCaptureButtonSize) + .background(Color.White) + ) + }, + flipCameraButton = { btnModifier -> + Box( + modifier = btnModifier + .size(PreviewLayoutTokens.PreviewControlSize) + .background(Color.Cyan) + ) + }, + imageWell = { wellModifier -> + Box( + modifier = wellModifier + .size(PreviewLayoutTokens.PreviewControlSize) + .background(Color.Cyan) + ) + }, + zoomLevelDisplay = { zoomModifier -> + Box( + modifier = zoomModifier + .height(PreviewLayoutTokens.PreviewControlSize) + .fillMaxWidth() + .background(Color.Magenta) + ) + }, + elapsedTimeDisplay = { elapsedModifier -> + Box( + modifier = elapsedModifier + .height(PreviewLayoutTokens.PreviewElapsedHeight) + .fillMaxWidth(PreviewLayoutTokens.PREVIEW_HALF_WIDTH_FRACTION) + .background(Color.Red) + ) + }, + quickSettingsButton = { qsModifier -> + Box( + modifier = qsModifier + .size(PreviewLayoutTokens.PreviewControlSize) + .background(Color.Yellow) + ) + }, + indicatorRow = { indModifier -> + Box( + modifier = indModifier + .height(PreviewLayoutTokens.PreviewControlSize) + .fillMaxWidth() + .background(Color.Green) + ) + }, + captureModeToggle = { toggleModifier -> + Box( + modifier = toggleModifier + .height(PreviewLayoutTokens.PreviewControlSize) + .fillMaxWidth(PreviewLayoutTokens.PREVIEW_HALF_WIDTH_FRACTION) + .background(Color.Blue) + ) + }, + quickSettingsOverlay = {}, + debugOverlay = {}, + screenFlashOverlay = {}, + snackBar = { _, _ -> }, + debugVisibilityWrapper = { content -> content() }, + slots = slots + ) + } +} + @PreviewPortraitDevices @Composable private fun CaptureLayoutPreview() { - PreviewLayout( - modifier = Modifier.background(Color.Black), - viewfinder = { modifier -> - Box( - modifier = modifier - .fillMaxWidth() - .aspectRatio(PreviewLayoutTokens.PREVIEW_VIEWFINDER_ASPECT_RATIO) - .background(Color.DarkGray) - ) - }, - captureButton = { modifier -> - Box( - modifier = modifier - .size(PreviewLayoutTokens.PreviewCaptureButtonSize) - .background(Color.White) - ) - }, - flipCameraButton = { modifier -> - Box( - modifier = modifier - .size(PreviewLayoutTokens.PreviewControlSize) - .background(Color.Cyan) - ) - }, - imageWell = { modifier -> - Box( - modifier = modifier - .size(PreviewLayoutTokens.PreviewControlSize) - .background(Color.Cyan) - ) - }, - zoomLevelDisplay = { modifier -> - Box( - modifier = modifier - .height(PreviewLayoutTokens.PreviewControlSize) - .fillMaxWidth() - .background(Color.Magenta) - ) - }, - elapsedTimeDisplay = { modifier -> - Box( - modifier = modifier - .height(PreviewLayoutTokens.PreviewElapsedHeight) - .fillMaxWidth(PreviewLayoutTokens.PREVIEW_HALF_WIDTH_FRACTION) - .background(Color.Red) - ) - }, - quickSettingsButton = { modifier -> - Box( - modifier = modifier - .size(PreviewLayoutTokens.PreviewControlSize) - .background(Color.Yellow) - ) - }, - indicatorRow = { modifier -> - Box( - modifier = modifier - .height(PreviewLayoutTokens.PreviewControlSize) - .fillMaxWidth() - .background(Color.Green) - ) - }, - captureModeToggle = { modifier -> - Box( - modifier = modifier - .height(PreviewLayoutTokens.PreviewControlSize) - .fillMaxWidth(PreviewLayoutTokens.PREVIEW_HALF_WIDTH_FRACTION) - .background(Color.Blue) - ) - }, - quickSettingsOverlay = { - // No-op for preview - }, - debugOverlay = { - // No-op for preview - }, - screenFlashOverlay = { - // No-op for preview - }, - snackBar = { _, _ -> - // No-op for preview - }, - debugVisibilityWrapper = { content -> content() } + AdaptivePreviewLayoutSample() +} + +@Preview( + name = "PreviewLayout - COMPACT_PORTRAIT (412x915dp)", + device = "spec:width=412dp,height=915dp", + showBackground = true +) +@Composable +private fun PreviewLayoutCompactPortraitPreview() { + AdaptivePreviewLayoutSample( + slots = PlaceholderCaptureSlots.sideRail(includeElapsedTime = true) + ) +} + +@Preview( + name = "PreviewLayout - TWO_SIDE_RAIL (852x883dp)", + device = "spec:width=852dp,height=883dp", + showBackground = true +) +@Composable +private fun PreviewLayoutTwoSideRailPreview() { + AdaptivePreviewLayoutSample( + slots = PlaceholderCaptureSlots.sideRail(includeElapsedTime = true) + ) +} + +@Preview( + name = "PreviewLayout - WIDE_SHORT Split (800x400dp on sw600dp)", + device = "spec:width=800dp,height=600dp", + showBackground = true +) +@Composable +private fun PreviewLayoutWideShortPreview() { + AdaptivePreviewLayoutSample( + modifier = Modifier.size( + width = PreviewLayoutTokens.PreviewWideShortWidth, + height = PreviewLayoutTokens.PreviewWideShortHeight + ), + slots = PlaceholderCaptureSlots.sideRail( + isCompactHeight = true, + includeElapsedTime = true + ) + ) +} + +@Preview( + name = "PreviewLayout - COMPACT_SHORT (360x360dp)", + device = "spec:width=360dp,height=360dp", + showBackground = true +) +@Composable +private fun PreviewLayoutCompactShortPreview() { + AdaptivePreviewLayoutSample( + slots = PlaceholderCaptureSlots.sideRail( + isCompactHeight = true, + includeElapsedTime = true + ) ) } diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/layout/CameraLayoutMode.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/layout/CameraLayoutMode.kt new file mode 100644 index 0000000000..c22de318ac --- /dev/null +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/layout/CameraLayoutMode.kt @@ -0,0 +1,324 @@ +/* + * 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.layout + +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper +import androidx.activity.ComponentActivity +import androidx.activity.compose.LocalActivity +import androidx.annotation.VisibleForTesting +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.core.app.MultiWindowModeChangedInfo +import androidx.core.util.Consumer +import kotlin.math.abs + +private object CameraLayoutModeTokens { + val SlimDimensionBreakpoint = 360.dp + val ShortDimensionBreakpoint = 480.dp + val MediumWidthBreakpoint = 600.dp + val HysteresisHalfBand = 7.dp + + val EnterSlimThreshold = SlimDimensionBreakpoint - HysteresisHalfBand + val ExitSlimThreshold = SlimDimensionBreakpoint + HysteresisHalfBand + + val EnterAtLeast480Threshold = ShortDimensionBreakpoint + HysteresisHalfBand + val ExitAtLeast480Threshold = ShortDimensionBreakpoint - HysteresisHalfBand + + val EnterWide600Threshold = MediumWidthBreakpoint + HysteresisHalfBand + val ExitWide600Threshold = MediumWidthBreakpoint - HysteresisHalfBand + + val MinFullScreenCompactLongEdge = 480.dp + + const val MIN_VALID_SMALLEST_SCREEN_WIDTH_DP = 1 + const val COMPACT_SMALLEST_SCREEN_WIDTH_UPPER_BOUND_DP = 600 + const val FULL_SCREEN_DIMENSION_TOLERANCE_DP = 2f +} + +/** + * Adaptive layout classification for the camera capture screen based on available window width, + * height, and multi-window posture. + */ +enum class CameraLayoutMode { + /** + * Upright single-column bottom control stack used on full-screen portrait compact phones and + * portrait-shaped multi-window panes (`widthDp < 600.dp`, `heightDp >= 480.dp`, and + * `minOf(widthDp, heightDp) >= 360.dp`). + */ + COMPACT_PORTRAIT, + + /** + * Horizontally oriented (`90°`-rotated) compact layout used when a window is short vertically + * (`heightDp < 480.dp`) with sufficient horizontal span (`widthDp >= 480.dp` and + * `minOf(widthDp, heightDp) >= 360.dp`), or when a full-screen compact phone window is wider + * than tall. + */ + COMPACT_HORIZONTAL, + + /** + * Narrow/slim compact posture (`minOf(widthDp, heightDp) < 360.dp` and + * `maxOf(widthDp, heightDp) >= 480.dp`, e.g., slim outer foldable displays or 1/3 split-screen + * panes). Currently delegates to `CompactPortraitLayout` while providing a dedicated mode for + * future component hiding/compaction. + */ + COMPACT_SLIM, + + /** + * Compact, short-on-both-axes posture (`widthDp < 480.dp` and `heightDp < 480.dp` with + * `minOf(widthDp, heightDp) >= 360.dp`, e.g., 50/50 phone split-screen panes or clamshell cover + * screens). + */ + COMPACT_SHORT, + + /** + * Ultra-compact slim and short posture (`widthDp < 480.dp`, `heightDp < 480.dp`, and + * `minOf(widthDp, heightDp) < 360.dp`, e.g., small cover screens or compact freeform windows). + */ + COMPACT_SHORT_SLIM, + + /** + * Dual start and end vertical control rails over a full-bleed viewfinder used on unfolded + * foldables and tablets (`widthDp >= 600.dp` and `heightDp >= 480.dp`). + */ + TWO_SIDE_RAIL +} + +/** + * CompositionLocal providing the active [CameraLayoutMode] resolved by `PreviewLayout`. + * + * Defaults to [CameraLayoutMode.COMPACT_PORTRAIT] when read outside an adaptive orchestrator. + */ +val LocalCameraLayoutMode: ProvidableCompositionLocal = + compositionLocalOf { CameraLayoutMode.COMPACT_PORTRAIT } + +/** + * Classifies the capture window geometry into a [CameraLayoutMode], applying a `14.dp` (`±7.dp`) + * hysteresis deadband around the `360.dp` slim (`[353.dp, 367.dp]`), `480.dp` short + * (`[473.dp, 487.dp]`), and `600.dp` wide (`[593.dp, 607.dp]`) breakpoints to prevent layout + * thrashing during live split-screen divider drags or desktop freeform window resizing. + * + * @param widthDp Available capture container width in `dp`. + * @param heightDp Available capture container height in `dp`. + * @param isFullScreenCompactPhone Whether the container occupies a full-screen compact phone + * window (`sw < 600dp`, not in multi-window mode, with long edge `>= 480.dp`). + * @param previousMode Previously committed [CameraLayoutMode] used to bias threshold crossings + * across the hysteresis deadband, or `null` on initial classification. + * @return The resolved [CameraLayoutMode] for the given window dimensions. + */ +fun classifyCameraLayoutMode( + widthDp: Dp, + heightDp: Dp, + isFullScreenCompactPhone: Boolean = false, + previousMode: CameraLayoutMode? = null +): CameraLayoutMode { + val minDim = minOf(widthDp, heightDp) + val isSlim = when (previousMode) { + null -> minDim < CameraLayoutModeTokens.SlimDimensionBreakpoint + CameraLayoutMode.COMPACT_SLIM, + CameraLayoutMode.COMPACT_SHORT_SLIM -> + minDim < CameraLayoutModeTokens.ExitSlimThreshold + CameraLayoutMode.COMPACT_PORTRAIT, + CameraLayoutMode.COMPACT_HORIZONTAL, + CameraLayoutMode.COMPACT_SHORT, + CameraLayoutMode.TWO_SIDE_RAIL -> + minDim < CameraLayoutModeTokens.EnterSlimThreshold + } + + if (isFullScreenCompactPhone) { + return when { + isSlim -> CameraLayoutMode.COMPACT_SLIM + widthDp > heightDp -> CameraLayoutMode.COMPACT_HORIZONTAL + else -> CameraLayoutMode.COMPACT_PORTRAIT + } + } + + val isWide600 = when (previousMode) { + null -> widthDp >= CameraLayoutModeTokens.MediumWidthBreakpoint + CameraLayoutMode.TWO_SIDE_RAIL -> widthDp >= CameraLayoutModeTokens.ExitWide600Threshold + CameraLayoutMode.COMPACT_PORTRAIT, + CameraLayoutMode.COMPACT_HORIZONTAL, + CameraLayoutMode.COMPACT_SLIM, + CameraLayoutMode.COMPACT_SHORT, + CameraLayoutMode.COMPACT_SHORT_SLIM -> + widthDp >= CameraLayoutModeTokens.EnterWide600Threshold + } + + val isWidthAtLeast480 = when (previousMode) { + null -> widthDp >= CameraLayoutModeTokens.ShortDimensionBreakpoint + CameraLayoutMode.COMPACT_HORIZONTAL, + CameraLayoutMode.TWO_SIDE_RAIL -> + widthDp >= CameraLayoutModeTokens.ExitAtLeast480Threshold + CameraLayoutMode.COMPACT_SHORT, + CameraLayoutMode.COMPACT_SHORT_SLIM, + CameraLayoutMode.COMPACT_PORTRAIT -> + widthDp >= CameraLayoutModeTokens.EnterAtLeast480Threshold + CameraLayoutMode.COMPACT_SLIM -> + if (widthDp >= heightDp) { + widthDp >= CameraLayoutModeTokens.ExitAtLeast480Threshold + } else { + widthDp >= CameraLayoutModeTokens.EnterAtLeast480Threshold + } + } + + val isHeightAtLeast480 = when (previousMode) { + null -> heightDp >= CameraLayoutModeTokens.ShortDimensionBreakpoint + CameraLayoutMode.COMPACT_PORTRAIT, + CameraLayoutMode.TWO_SIDE_RAIL -> + heightDp >= CameraLayoutModeTokens.ExitAtLeast480Threshold + CameraLayoutMode.COMPACT_HORIZONTAL, + CameraLayoutMode.COMPACT_SHORT, + CameraLayoutMode.COMPACT_SHORT_SLIM -> + heightDp >= CameraLayoutModeTokens.EnterAtLeast480Threshold + CameraLayoutMode.COMPACT_SLIM -> + if (heightDp >= widthDp) { + heightDp >= CameraLayoutModeTokens.ExitAtLeast480Threshold + } else { + heightDp >= CameraLayoutModeTokens.EnterAtLeast480Threshold + } + } + + return when { + isWide600 && isHeightAtLeast480 -> CameraLayoutMode.TWO_SIDE_RAIL + !isWidthAtLeast480 && !isHeightAtLeast480 -> + if (isSlim) { + CameraLayoutMode.COMPACT_SHORT_SLIM + } else { + CameraLayoutMode.COMPACT_SHORT + } + isSlim -> CameraLayoutMode.COMPACT_SLIM + !isHeightAtLeast480 -> CameraLayoutMode.COMPACT_HORIZONTAL + else -> CameraLayoutMode.COMPACT_PORTRAIT + } +} + +/** + * Determines whether a capture container represents a full-screen compact phone window. + * + * Returns `false` when the activity is in multi-window mode, when the device's smallest screen + * width is outside `1 until 600` (tablets and unfolded foldables), when both container edges are + * shorter than `480.dp` (such as a `360x360dp` clamshell cover display), or when the container + * bounds differ from the full screen configuration by more than `2.dp` (such as sub-window panes + * or dynamic resize containers). + */ +@VisibleForTesting +internal fun isFullScreenCompactPhoneWindow( + widthDp: Dp, + heightDp: Dp, + smallestScreenWidthDp: Int, + screenWidthDp: Int, + screenHeightDp: Int, + isInMultiWindowMode: Boolean +): Boolean { + if (isInMultiWindowMode) return false + if ( + smallestScreenWidthDp !in CameraLayoutModeTokens.MIN_VALID_SMALLEST_SCREEN_WIDTH_DP until + CameraLayoutModeTokens.COMPACT_SMALLEST_SCREEN_WIDTH_UPPER_BOUND_DP + ) { + return false + } + if (maxOf(widthDp, heightDp) < CameraLayoutModeTokens.MinFullScreenCompactLongEdge) { + return false + } + return abs(widthDp.value - screenWidthDp) <= + CameraLayoutModeTokens.FULL_SCREEN_DIMENSION_TOLERANCE_DP && + abs(heightDp.value - screenHeightDp) <= + CameraLayoutModeTokens.FULL_SCREEN_DIMENSION_TOLERANCE_DP +} + +/** + * Remembers whether the current Compose container occupies a full-screen compact phone window, + * observing `LocalConfiguration` and `Activity.isInMultiWindowMode` (API 24+). + */ +@Composable +internal fun rememberIsFullScreenCompactPhone(widthDp: Dp, heightDp: Dp): Boolean { + val configuration = LocalConfiguration.current + val context = LocalContext.current + val activity = LocalActivity.current ?: remember(context) { context.findActivity() } + + var isInMultiWindowMode by remember(activity) { + mutableStateOf(activity?.isInMultiWindowMode ?: false) + } + DisposableEffect(activity) { + isInMultiWindowMode = activity?.isInMultiWindowMode ?: false + val componentActivity = activity as? ComponentActivity + val listener = Consumer { info -> + isInMultiWindowMode = info.isInMultiWindowMode + } + componentActivity?.addOnMultiWindowModeChangedListener(listener) + onDispose { + componentActivity?.removeOnMultiWindowModeChangedListener(listener) + } + } + + return isFullScreenCompactPhoneWindow( + widthDp = widthDp, + heightDp = heightDp, + smallestScreenWidthDp = configuration.smallestScreenWidthDp, + screenWidthDp = configuration.screenWidthDp, + screenHeightDp = configuration.screenHeightDp, + isInMultiWindowMode = isInMultiWindowMode || (activity?.isInMultiWindowMode == true) + ) +} + +/** + * Remembers the active [CameraLayoutMode] for [widthDp] and [heightDp], persisting the last + * committed mode across recompositions via [SideEffect] so the `14.dp` (`±7.dp`) hysteresis + * deadband prevents rapid oscillation near the `360.dp`, `480.dp`, and `600.dp` breakpoints. + * + * @param widthDp Available capture container width in `dp`. + * @param heightDp Available capture container height in `dp`. + * @param isFullScreenCompactPhone Whether the container occupies a full-screen compact phone + * window; defaults to [rememberIsFullScreenCompactPhone]. + * @return The hysteresis-stabilized [CameraLayoutMode]. + */ +@Composable +fun rememberCameraLayoutMode( + widthDp: Dp, + heightDp: Dp, + isFullScreenCompactPhone: Boolean = rememberIsFullScreenCompactPhone(widthDp, heightDp) +): CameraLayoutMode { + val previousModeHolder = remember { PreviousLayoutModeHolder() } + val resolvedMode = classifyCameraLayoutMode( + widthDp = widthDp, + heightDp = heightDp, + isFullScreenCompactPhone = isFullScreenCompactPhone, + previousMode = previousModeHolder.mode + ) + SideEffect { + previousModeHolder.mode = resolvedMode + } + return resolvedMode +} + +private class PreviousLayoutModeHolder(var mode: CameraLayoutMode? = null) + +private tailrec fun Context.findActivity(): Activity? = when (this) { + is Activity -> this + is ContextWrapper -> baseContext.findActivity() + else -> null +} diff --git a/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/AdaptiveCaptureLayoutTest.kt b/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/AdaptiveCaptureLayoutTest.kt new file mode 100644 index 0000000000..8a0cfdbf5c --- /dev/null +++ b/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/AdaptiveCaptureLayoutTest.kt @@ -0,0 +1,461 @@ +/* + * 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.view.View +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.material3.BottomSheetScaffoldState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SheetValue +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.getUnclippedBoundsInRoot +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.core.graphics.Insets +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import com.google.common.truth.Truth.assertThat +import com.google.jetpackcamera.ui.components.capture.layout.CameraLayoutMode +import com.google.jetpackcamera.ui.components.capture.layout.LocalCameraLayoutMode +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +private object TestTokens { + const val MULTI_WINDOW_STATUS_BAR_PX = 36 + const val BOUNDS_TOLERANCE_DP = 0.5f + + val CaptureButtonSize = 80.dp + val SecondaryControlSize = 48.dp + val ZoomControlWidth = 160.dp + val IndicatorWidth = 120.dp + val IndicatorHeight = 36.dp + val ModeToggleWidth = 96.dp +} + +/** + * Robolectric Compose UI tests for [PreviewLayout] adaptive orchestration across device qualifiers, + * Quick Settings single-tag guarantees, and `movableContentOf` viewfinder preservation across all + * [CameraLayoutMode] states and hysteresis deadbands. + */ +@OptIn(ExperimentalMaterial3Api::class) +@RunWith(RobolectricTestRunner::class) +class AdaptiveCaptureLayoutTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Composable + private fun OrchestratedPreviewUnderTest( + modifier: Modifier = Modifier, + scaffoldState: BottomSheetScaffoldState = rememberCompactPortraitScaffoldState(), + slots: CaptureSlots? = null, + onModeObserved: (CameraLayoutMode) -> Unit = {}, + viewfinderContent: @Composable (Modifier) -> Unit = { vfMod -> + PlaceholderViewfinder(modifier = vfMod) + } + ) { + MaterialTheme { + PreviewLayout( + modifier = modifier, + scaffoldState = scaffoldState, + viewfinder = { vfModifier -> + onModeObserved(LocalCameraLayoutMode.current) + viewfinderContent(vfModifier) + }, + captureButton = { btnMod -> + Box(btnMod.size(TestTokens.CaptureButtonSize).testTag(CAPTURE_BUTTON)) + }, + imageWell = { wellMod -> + Box(wellMod.size(TestTokens.SecondaryControlSize).testTag(IMAGE_WELL_TAG)) + }, + flipCameraButton = { flipMod -> + Box(flipMod.size(TestTokens.SecondaryControlSize).testTag(FLIP_CAMERA_BUTTON)) + }, + zoomLevelDisplay = { zoomMod -> + Box( + zoomMod + .size( + width = TestTokens.ZoomControlWidth, + height = TestTokens.SecondaryControlSize + ) + .testTag(ZOOM_BUTTON_ROW_TAG) + ) + }, + elapsedTimeDisplay = {}, + quickSettingsButton = { qsMod -> + Box( + qsMod.size( + TestTokens.SecondaryControlSize + ).testTag(QUICK_SETTINGS_DROP_DOWN) + ) + }, + indicatorRow = { indMod -> + Box( + indMod + .size( + width = TestTokens.IndicatorWidth, + height = TestTokens.IndicatorHeight + ) + .testTag(VIDEO_QUALITY_TAG) + ) + }, + captureModeToggle = { modeMod -> + Box( + modeMod + .size( + width = TestTokens.ModeToggleWidth, + height = TestTokens.SecondaryControlSize + ) + .testTag(CAPTURE_MODE_TOGGLE_BUTTON) + ) + }, + quickSettingsOverlay = { qsOverlayMod -> + Box( + qsOverlayMod + .size(TestTokens.SecondaryControlSize) + .testTag(QUICK_SETTINGS_BOTTOM_SHEET) + ) + }, + debugOverlay = {}, + debugVisibilityWrapper = { content -> content() }, + screenFlashOverlay = {}, + snackBar = { _, _ -> }, + slots = slots + ) + } + } + + private fun assertCompactLayoutTopology( + observedMode: CameraLayoutMode?, + expectedMode: CameraLayoutMode = CameraLayoutMode.COMPACT_PORTRAIT + ) { + assertThat(observedMode).isEqualTo(expectedMode) + composeTestRule.onNodeWithTag(COMPACT_CONTROLS_TAG).assertIsDisplayed() + composeTestRule.onNodeWithTag(START_SIDE_RAIL_TAG).assertDoesNotExist() + composeTestRule.onNodeWithTag(END_SIDE_RAIL_TAG).assertDoesNotExist() + } + + private fun assertSideRailTopology( + observedMode: CameraLayoutMode?, + expectedMode: CameraLayoutMode + ) { + assertThat(observedMode).isEqualTo(expectedMode) + composeTestRule.onNodeWithTag(START_SIDE_RAIL_TAG).assertIsDisplayed() + composeTestRule.onNodeWithTag(END_SIDE_RAIL_TAG).assertIsDisplayed() + composeTestRule.onNodeWithTag(COMPACT_CONTROLS_TAG).assertDoesNotExist() + } + + @Config(qualifiers = "w360dp-h740dp-normal-long-port-mdpi") + @Test + fun standardPhonePortrait_w360dp_h740dp_rendersCompactPortraitWithoutSideRails() { + var observedMode: CameraLayoutMode? = null + + composeTestRule.setContent { + OrchestratedPreviewUnderTest(onModeObserved = { observedMode = it }) + } + composeTestRule.waitForIdle() + + assertCompactLayoutTopology(observedMode, CameraLayoutMode.COMPACT_PORTRAIT) + } + + @Config(qualifiers = "sw360dp-w740dp-h360dp-normal-long-land-mdpi") + @Test + fun standardPhoneLandscapeFullScreen_sw360dp_w740dp_h360dp_resolvesToCompactHorizontal() { + var observedMode: CameraLayoutMode? = null + + composeTestRule.setContent { + OrchestratedPreviewUnderTest(onModeObserved = { observedMode = it }) + } + composeTestRule.waitForIdle() + + assertCompactLayoutTopology(observedMode, CameraLayoutMode.COMPACT_HORIZONTAL) + } + + @Config(qualifiers = "sw320dp-w320dp-h640dp-port-mdpi") + @Test + fun slimDisplay_sw320dp_w320dp_h640dp_resolvesToCompactSlimUsingCompactLayout() { + var observedMode: CameraLayoutMode? = null + + composeTestRule.setContent { + OrchestratedPreviewUnderTest(onModeObserved = { observedMode = it }) + } + composeTestRule.waitForIdle() + + assertCompactLayoutTopology(observedMode, CameraLayoutMode.COMPACT_SLIM) + } + + @Config(qualifiers = "sw800dp-w480dp-h800dp-port-mdpi") + @Test + fun tabletNarrowSplitScreen_sw800dp_w480dp_h800dp_resolvesToCompactPortrait() { + var observedMode: CameraLayoutMode? = null + lateinit var hostView: View + + composeTestRule.setContent { + hostView = LocalView.current + OrchestratedPreviewUnderTest(onModeObserved = { observedMode = it }) + } + composeTestRule.waitForIdle() + + assertCompactLayoutTopology(observedMode, CameraLayoutMode.COMPACT_PORTRAIT) + + val statusBarInsets = Insets.of(0, TestTokens.MULTI_WINDOW_STATUS_BAR_PX, 0, 0) + val insets = WindowInsetsCompat.Builder() + .setInsets(WindowInsetsCompat.Type.statusBars(), statusBarInsets) + .setInsetsIgnoringVisibility(WindowInsetsCompat.Type.statusBars(), statusBarInsets) + .setVisible(WindowInsetsCompat.Type.statusBars(), true) + .build() + composeTestRule.runOnUiThread { + ViewCompat.dispatchApplyWindowInsets(hostView, insets) + } + composeTestRule.waitForIdle() + + val indicatorBounds = + composeTestRule.onNodeWithTag(VIDEO_QUALITY_TAG).getUnclippedBoundsInRoot() + assertThat(indicatorBounds.top.value) + .isAtLeast( + TestTokens.MULTI_WINDOW_STATUS_BAR_PX.toFloat() - TestTokens.BOUNDS_TOLERANCE_DP + ) + } + + @Config(qualifiers = "sw673dp-w852dp-h883dp-mdpi") + @Test + fun unfoldedFoldableInnerDisplay_portrait_resolvesToTwoSideRail() { + var observedMode: CameraLayoutMode? = null + + composeTestRule.setContent { + OrchestratedPreviewUnderTest(onModeObserved = { observedMode = it }) + } + composeTestRule.waitForIdle() + + assertSideRailTopology(observedMode, CameraLayoutMode.TWO_SIDE_RAIL) + } + + @Config(qualifiers = "sw673dp-w883dp-h852dp-land-mdpi") + @Test + fun unfoldedFoldableInnerDisplay_landscape_resolvesToTwoSideRail() { + var observedMode: CameraLayoutMode? = null + + composeTestRule.setContent { + OrchestratedPreviewUnderTest(onModeObserved = { observedMode = it }) + } + composeTestRule.waitForIdle() + + assertSideRailTopology(observedMode, CameraLayoutMode.TWO_SIDE_RAIL) + } + + @Config(qualifiers = "sw800dp-w1280dp-h800dp-land-mdpi") + @Test + fun tabletLandscape_resolvesToTwoSideRail() { + var observedMode: CameraLayoutMode? = null + val isQuickSettingsExpanded = mutableStateOf(false) + + composeTestRule.setContent { + val scaffoldState = if (isQuickSettingsExpanded.value) { + rememberCompactPortraitScaffoldState(initialValue = SheetValue.Expanded) + } else { + rememberCompactPortraitScaffoldState(initialValue = SheetValue.Hidden) + } + OrchestratedPreviewUnderTest( + scaffoldState = scaffoldState, + onModeObserved = { observedMode = it } + ) + } + composeTestRule.waitForIdle() + + assertSideRailTopology(observedMode, CameraLayoutMode.TWO_SIDE_RAIL) + composeTestRule.onNodeWithTag(QUICK_SETTINGS_BOTTOM_SHEET).assertDoesNotExist() + + isQuickSettingsExpanded.value = true + composeTestRule.waitForIdle() + assertThat( + composeTestRule.onAllNodesWithTag(QUICK_SETTINGS_BOTTOM_SHEET).fetchSemanticsNodes() + ).hasSize(1) + } + + @Config(qualifiers = "sw800dp-w800dp-h1280dp-port-mdpi") + @Test + fun tabletPortrait_resolvesToTwoSideRail() { + var observedMode: CameraLayoutMode? = null + + composeTestRule.setContent { + OrchestratedPreviewUnderTest(onModeObserved = { observedMode = it }) + } + composeTestRule.waitForIdle() + + assertSideRailTopology(observedMode, CameraLayoutMode.TWO_SIDE_RAIL) + } + + @Config(qualifiers = "sw600dp-w800dp-h400dp-land-mdpi") + @Test + fun horizontalSplitScreen_sw600dp_w800dp_h400dp_resolvesToCompactHorizontal() { + var observedMode: CameraLayoutMode? = null + + composeTestRule.setContent { + OrchestratedPreviewUnderTest(onModeObserved = { observedMode = it }) + } + composeTestRule.waitForIdle() + + assertCompactLayoutTopology(observedMode, CameraLayoutMode.COMPACT_HORIZONTAL) + } + + @Config(qualifiers = "sw360dp-w360dp-h360dp-mdpi") + @Test + fun clamshellCoverOrPhoneSplit_sw360dp_w360dp_h360dp_resolvesToCompactShort() { + var observedMode: CameraLayoutMode? = null + val isQuickSettingsExpanded = mutableStateOf(false) + + composeTestRule.setContent { + val scaffoldState = if (isQuickSettingsExpanded.value) { + rememberCompactPortraitScaffoldState(initialValue = SheetValue.Expanded) + } else { + rememberCompactPortraitScaffoldState(initialValue = SheetValue.Hidden) + } + OrchestratedPreviewUnderTest( + scaffoldState = scaffoldState, + onModeObserved = { observedMode = it } + ) + } + composeTestRule.waitForIdle() + + assertSideRailTopology(observedMode, CameraLayoutMode.COMPACT_SHORT) + composeTestRule.onNodeWithTag(QUICK_SETTINGS_BOTTOM_SHEET).assertDoesNotExist() + + isQuickSettingsExpanded.value = true + composeTestRule.waitForIdle() + assertThat( + composeTestRule.onAllNodesWithTag(QUICK_SETTINGS_BOTTOM_SHEET).fetchSemanticsNodes() + ).hasSize(1) + } + + @Config(qualifiers = "sw320dp-w320dp-h340dp-mdpi") + @Test + fun tinyCoverOrSplit_sw320dp_w320dp_h340dp_resolvesToCompactShortSlim() { + var observedMode: CameraLayoutMode? = null + + composeTestRule.setContent { + OrchestratedPreviewUnderTest(onModeObserved = { observedMode = it }) + } + composeTestRule.waitForIdle() + + assertSideRailTopology(observedMode, CameraLayoutMode.COMPACT_SHORT_SLIM) + } + + @Config(qualifiers = "sw800dp-w1280dp-h1280dp-mdpi") + @Test + fun dynamicDividerResize_acrossAllSixModes_preservesViewfinderWithMovableContentOf() { + val windowSizeState = mutableStateOf(DpSize(400.dp, 800.dp)) + var composeCount = 0 + var disposeCount = 0 + var observedMode: CameraLayoutMode? = null + + composeTestRule.setContent { + Box(modifier = Modifier.size(windowSizeState.value)) { + OrchestratedPreviewUnderTest( + slots = PlaceholderCaptureSlots.sideRail(includeElapsedTime = true), + onModeObserved = { observedMode = it }, + viewfinderContent = { vfModifier -> + DisposableEffect(Unit) { + composeCount++ + onDispose { disposeCount++ } + } + Box( + modifier = vfModifier + .fillMaxSize() + .testTag(PREVIEW_DISPLAY) + ) + } + ) + } + } + composeTestRule.waitForIdle() + + // Step 1: 400x800dp -> COMPACT_PORTRAIT. + assertCompactLayoutTopology(observedMode, CameraLayoutMode.COMPACT_PORTRAIT) + assertThat(composeCount).isEqualTo(1) + assertThat(disposeCount).isEqualTo(0) + + // Step 2: 604x800dp -> inside [593dp, 607dp] width deadband -> stays COMPACT_PORTRAIT. + windowSizeState.value = DpSize(604.dp, 800.dp) + composeTestRule.waitForIdle() + assertCompactLayoutTopology(observedMode, CameraLayoutMode.COMPACT_PORTRAIT) + assertThat(composeCount).isEqualTo(1) + assertThat(disposeCount).isEqualTo(0) + + // Step 3: 852x883dp -> crosses width deadband -> TWO_SIDE_RAIL. + windowSizeState.value = DpSize(852.dp, 883.dp) + composeTestRule.waitForIdle() + assertSideRailTopology(observedMode, CameraLayoutMode.TWO_SIDE_RAIL) + assertThat(composeCount).isEqualTo(1) + assertThat(disposeCount).isEqualTo(0) + + // Step 4: 852x476dp -> inside [473dp, 487dp] height deadband -> stays TWO_SIDE_RAIL. + windowSizeState.value = DpSize(852.dp, 476.dp) + composeTestRule.waitForIdle() + assertSideRailTopology(observedMode, CameraLayoutMode.TWO_SIDE_RAIL) + assertThat(composeCount).isEqualTo(1) + assertThat(disposeCount).isEqualTo(0) + + // Step 5: 800x400dp -> crosses height deadband -> COMPACT_HORIZONTAL. + windowSizeState.value = DpSize(800.dp, 400.dp) + composeTestRule.waitForIdle() + assertCompactLayoutTopology(observedMode, CameraLayoutMode.COMPACT_HORIZONTAL) + assertThat(composeCount).isEqualTo(1) + assertThat(disposeCount).isEqualTo(0) + + // Step 6: 400x400dp -> crosses 480dp width deadband -> COMPACT_SHORT. + windowSizeState.value = DpSize(400.dp, 400.dp) + composeTestRule.waitForIdle() + assertSideRailTopology(observedMode, CameraLayoutMode.COMPACT_SHORT) + assertThat(composeCount).isEqualTo(1) + assertThat(disposeCount).isEqualTo(0) + + // Step 7: 320x340dp -> crosses 360dp slim deadband -> COMPACT_SHORT_SLIM. + windowSizeState.value = DpSize(320.dp, 340.dp) + composeTestRule.waitForIdle() + assertSideRailTopology(observedMode, CameraLayoutMode.COMPACT_SHORT_SLIM) + assertThat(composeCount).isEqualTo(1) + assertThat(disposeCount).isEqualTo(0) + + // Step 8: 320x640dp -> crosses 480dp height deadband while slim -> COMPACT_SLIM. + windowSizeState.value = DpSize(320.dp, 640.dp) + composeTestRule.waitForIdle() + assertCompactLayoutTopology(observedMode, CameraLayoutMode.COMPACT_SLIM) + assertThat(composeCount).isEqualTo(1) + assertThat(disposeCount).isEqualTo(0) + + // Step 9: 400x800dp -> returns to COMPACT_PORTRAIT without ever disposing viewfinder. + windowSizeState.value = DpSize(400.dp, 800.dp) + composeTestRule.waitForIdle() + assertCompactLayoutTopology(observedMode, CameraLayoutMode.COMPACT_PORTRAIT) + assertThat(composeCount).isEqualTo(1) + assertThat(disposeCount).isEqualTo(0) + } +} diff --git a/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/layout/CameraLayoutModeTest.kt b/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/layout/CameraLayoutModeTest.kt new file mode 100644 index 0000000000..be5d85f509 --- /dev/null +++ b/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/layout/CameraLayoutModeTest.kt @@ -0,0 +1,317 @@ +/* + * 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.layout + +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +/** + * Unit tests for [classifyCameraLayoutMode] and [isFullScreenCompactPhoneWindow], verifying + * initial 6-mode classification, split-screen rotation pair consistency, full-screen compact phone + * classification, and the `14.dp` (`±7.dp`) hysteresis deadbands around `360.dp` slim + * (`[353.dp, 367.dp]`), `480.dp` short (`[473.dp, 487.dp]`), and `600.dp` wide + * (`[593.dp, 607.dp]`). + */ +@RunWith(JUnit4::class) +class CameraLayoutModeTest { + + @Test + fun classifyCameraLayoutMode_initialClassification_resolvesAllSixModes() { + // COMPACT_PORTRAIT: width < 600dp, height >= 480dp, minDim >= 360dp + assertThat( + classifyCameraLayoutMode(widthDp = 412.dp, heightDp = 915.dp) + ).isEqualTo(CameraLayoutMode.COMPACT_PORTRAIT) + assertThat( + classifyCameraLayoutMode(widthDp = 599.dp, heightDp = 480.dp) + ).isEqualTo(CameraLayoutMode.COMPACT_PORTRAIT) + + // COMPACT_HORIZONTAL: width >= 480dp, height < 480dp, minDim >= 360dp + assertThat( + classifyCameraLayoutMode(widthDp = 800.dp, heightDp = 400.dp) + ).isEqualTo(CameraLayoutMode.COMPACT_HORIZONTAL) + assertThat( + classifyCameraLayoutMode(widthDp = 530.dp, heightDp = 412.dp) + ).isEqualTo(CameraLayoutMode.COMPACT_HORIZONTAL) + + // COMPACT_SLIM: minDim < 360dp, maxDim >= 480dp + assertThat( + classifyCameraLayoutMode(widthDp = 320.dp, heightDp = 640.dp) + ).isEqualTo(CameraLayoutMode.COMPACT_SLIM) + assertThat( + classifyCameraLayoutMode(widthDp = 640.dp, heightDp = 320.dp) + ).isEqualTo(CameraLayoutMode.COMPACT_SLIM) + + // COMPACT_SHORT: width < 480dp, height < 480dp, minDim >= 360dp + assertThat( + classifyCameraLayoutMode(widthDp = 412.dp, heightDp = 440.dp) + ).isEqualTo(CameraLayoutMode.COMPACT_SHORT) + assertThat( + classifyCameraLayoutMode(widthDp = 445.dp, heightDp = 412.dp) + ).isEqualTo(CameraLayoutMode.COMPACT_SHORT) + assertThat( + classifyCameraLayoutMode(widthDp = 360.dp, heightDp = 360.dp) + ).isEqualTo(CameraLayoutMode.COMPACT_SHORT) + + // COMPACT_SHORT_SLIM: width < 480dp, height < 480dp, minDim < 360dp + assertThat( + classifyCameraLayoutMode(widthDp = 320.dp, heightDp = 340.dp) + ).isEqualTo(CameraLayoutMode.COMPACT_SHORT_SLIM) + assertThat( + classifyCameraLayoutMode(widthDp = 340.dp, heightDp = 320.dp) + ).isEqualTo(CameraLayoutMode.COMPACT_SHORT_SLIM) + + // TWO_SIDE_RAIL: width >= 600dp, height >= 480dp + assertThat( + classifyCameraLayoutMode(widthDp = 852.dp, heightDp = 883.dp) + ).isEqualTo(CameraLayoutMode.TWO_SIDE_RAIL) + assertThat( + classifyCameraLayoutMode(widthDp = 600.dp, heightDp = 480.dp) + ).isEqualTo(CameraLayoutMode.TWO_SIDE_RAIL) + } + + @Test + fun classifyCameraLayoutMode_splitScreenRotationPairs_preserveLayoutFamily() { + // 60/40 compact split rotated: COMPACT_PORTRAIT <-> COMPACT_HORIZONTAL + val portraitSplit = classifyCameraLayoutMode(widthDp = 412.dp, heightDp = 530.dp) + val landscapeSplit = classifyCameraLayoutMode( + widthDp = 530.dp, + heightDp = 412.dp, + previousMode = portraitSplit + ) + assertThat(portraitSplit).isEqualTo(CameraLayoutMode.COMPACT_PORTRAIT) + assertThat(landscapeSplit).isEqualTo(CameraLayoutMode.COMPACT_HORIZONTAL) + + // 50/50 compact split rotated: stays COMPACT_SHORT + val squarePortrait = classifyCameraLayoutMode(widthDp = 412.dp, heightDp = 445.dp) + val squareLandscape = classifyCameraLayoutMode( + widthDp = 445.dp, + heightDp = 412.dp, + previousMode = squarePortrait + ) + assertThat(squarePortrait).isEqualTo(CameraLayoutMode.COMPACT_SHORT) + assertThat(squareLandscape).isEqualTo(CameraLayoutMode.COMPACT_SHORT) + + // Slim split rotated: stays COMPACT_SLIM + val slimPortrait = classifyCameraLayoutMode(widthDp = 320.dp, heightDp = 640.dp) + val slimLandscape = classifyCameraLayoutMode( + widthDp = 640.dp, + heightDp = 320.dp, + previousMode = slimPortrait + ) + assertThat(slimPortrait).isEqualTo(CameraLayoutMode.COMPACT_SLIM) + assertThat(slimLandscape).isEqualTo(CameraLayoutMode.COMPACT_SLIM) + } + + @Test + fun classifyCameraLayoutMode_fullScreenCompactPhone_resolvesPortraitHorizontalOrSlim() { + assertThat( + classifyCameraLayoutMode( + widthDp = 360.dp, + heightDp = 740.dp, + isFullScreenCompactPhone = true + ) + ).isEqualTo(CameraLayoutMode.COMPACT_PORTRAIT) + + assertThat( + classifyCameraLayoutMode( + widthDp = 740.dp, + heightDp = 360.dp, + isFullScreenCompactPhone = true + ) + ).isEqualTo(CameraLayoutMode.COMPACT_HORIZONTAL) + + assertThat( + classifyCameraLayoutMode( + widthDp = 915.dp, + heightDp = 500.dp, + isFullScreenCompactPhone = true + ) + ).isEqualTo(CameraLayoutMode.COMPACT_HORIZONTAL) + + assertThat( + classifyCameraLayoutMode( + widthDp = 320.dp, + heightDp = 740.dp, + isFullScreenCompactPhone = true + ) + ).isEqualTo(CameraLayoutMode.COMPACT_SLIM) + } + + @Test + fun classifyCameraLayoutMode_widthHysteresisSweep_respects14dpDeadbandAround600dp() { + var mode: CameraLayoutMode? = null + fun stepWidth(width: Dp): CameraLayoutMode { + mode = classifyCameraLayoutMode( + widthDp = width, + heightDp = 800.dp, + isFullScreenCompactPhone = false, + previousMode = mode + ) + return checkNotNull(mode) + } + + // Expanding from compact: stays COMPACT_PORTRAIT inside [593dp, 606dp], enters at >= 607dp. + assertThat(stepWidth(580.dp)).isEqualTo(CameraLayoutMode.COMPACT_PORTRAIT) + assertThat(stepWidth(600.dp)).isEqualTo(CameraLayoutMode.COMPACT_PORTRAIT) + assertThat(stepWidth(604.dp)).isEqualTo(CameraLayoutMode.COMPACT_PORTRAIT) + assertThat(stepWidth(606.dp)).isEqualTo(CameraLayoutMode.COMPACT_PORTRAIT) + assertThat(stepWidth(608.dp)).isEqualTo(CameraLayoutMode.TWO_SIDE_RAIL) + + // Shrinking from wide: stays TWO_SIDE_RAIL down to 593dp, exits below 593dp. + assertThat(stepWidth(600.dp)).isEqualTo(CameraLayoutMode.TWO_SIDE_RAIL) + assertThat(stepWidth(595.dp)).isEqualTo(CameraLayoutMode.TWO_SIDE_RAIL) + assertThat(stepWidth(594.dp)).isEqualTo(CameraLayoutMode.TWO_SIDE_RAIL) + assertThat(stepWidth(593.dp)).isEqualTo(CameraLayoutMode.TWO_SIDE_RAIL) + assertThat(stepWidth(592.dp)).isEqualTo(CameraLayoutMode.COMPACT_PORTRAIT) + } + + @Test + fun classifyCameraLayoutMode_widthHysteresisSweep_shortHeight_compactShortToHorizontal() { + var mode: CameraLayoutMode? = null + fun stepWidth(width: Dp): CameraLayoutMode { + mode = classifyCameraLayoutMode( + widthDp = width, + heightDp = 400.dp, + isFullScreenCompactPhone = false, + previousMode = mode + ) + return checkNotNull(mode) + } + + // Expanding across 480dp width while short: [473dp, 487dp] deadband + assertThat(stepWidth(460.dp)).isEqualTo(CameraLayoutMode.COMPACT_SHORT) + assertThat(stepWidth(480.dp)).isEqualTo(CameraLayoutMode.COMPACT_SHORT) + assertThat(stepWidth(486.dp)).isEqualTo(CameraLayoutMode.COMPACT_SHORT) + assertThat(stepWidth(487.dp)).isEqualTo(CameraLayoutMode.COMPACT_HORIZONTAL) + + assertThat(stepWidth(480.dp)).isEqualTo(CameraLayoutMode.COMPACT_HORIZONTAL) + assertThat(stepWidth(474.dp)).isEqualTo(CameraLayoutMode.COMPACT_HORIZONTAL) + assertThat(stepWidth(473.dp)).isEqualTo(CameraLayoutMode.COMPACT_HORIZONTAL) + assertThat(stepWidth(472.dp)).isEqualTo(CameraLayoutMode.COMPACT_SHORT) + } + + @Test + fun classifyCameraLayoutMode_heightHysteresisSweep_respects14dpDeadbandAround480dp_wide() { + var mode: CameraLayoutMode? = null + fun stepHeight(height: Dp): CameraLayoutMode { + mode = classifyCameraLayoutMode( + widthDp = 800.dp, + heightDp = height, + isFullScreenCompactPhone = false, + previousMode = mode + ) + return checkNotNull(mode) + } + + // Shrinking height from tall: stays TWO_SIDE_RAIL down to 473dp, exits below 473dp. + assertThat(stepHeight(500.dp)).isEqualTo(CameraLayoutMode.TWO_SIDE_RAIL) + assertThat(stepHeight(480.dp)).isEqualTo(CameraLayoutMode.TWO_SIDE_RAIL) + assertThat(stepHeight(474.dp)).isEqualTo(CameraLayoutMode.TWO_SIDE_RAIL) + assertThat(stepHeight(473.dp)).isEqualTo(CameraLayoutMode.TWO_SIDE_RAIL) + assertThat(stepHeight(472.dp)).isEqualTo(CameraLayoutMode.COMPACT_HORIZONTAL) + + // Growing height from short: stays COMPACT_HORIZONTAL up to 486dp, enters at >= 487dp. + assertThat(stepHeight(480.dp)).isEqualTo(CameraLayoutMode.COMPACT_HORIZONTAL) + assertThat(stepHeight(486.dp)).isEqualTo(CameraLayoutMode.COMPACT_HORIZONTAL) + assertThat(stepHeight(488.dp)).isEqualTo(CameraLayoutMode.TWO_SIDE_RAIL) + } + + @Test + fun classifyCameraLayoutMode_slimHysteresisSweep_respects14dpDeadbandAround360dp() { + var mode: CameraLayoutMode? = null + fun stepWidth(width: Dp): CameraLayoutMode { + mode = classifyCameraLayoutMode( + widthDp = width, + heightDp = 700.dp, + isFullScreenCompactPhone = false, + previousMode = mode + ) + return checkNotNull(mode) + } + + assertThat(stepWidth(380.dp)).isEqualTo(CameraLayoutMode.COMPACT_PORTRAIT) + assertThat(stepWidth(360.dp)).isEqualTo(CameraLayoutMode.COMPACT_PORTRAIT) + assertThat(stepWidth(354.dp)).isEqualTo(CameraLayoutMode.COMPACT_PORTRAIT) + assertThat(stepWidth(353.dp)).isEqualTo(CameraLayoutMode.COMPACT_PORTRAIT) + assertThat(stepWidth(352.dp)).isEqualTo(CameraLayoutMode.COMPACT_SLIM) + + assertThat(stepWidth(360.dp)).isEqualTo(CameraLayoutMode.COMPACT_SLIM) + assertThat(stepWidth(366.dp)).isEqualTo(CameraLayoutMode.COMPACT_SLIM) + assertThat(stepWidth(367.dp)).isEqualTo(CameraLayoutMode.COMPACT_PORTRAIT) + } + + @Test + fun isFullScreenCompactPhoneWindow_distinguishesFullScreenPhoneFromMultiWindowAndShortSplit() { + assertThat( + isFullScreenCompactPhoneWindow( + widthDp = 360.dp, + heightDp = 740.dp, + smallestScreenWidthDp = 360, + screenWidthDp = 360, + screenHeightDp = 740, + isInMultiWindowMode = false + ) + ).isTrue() + + assertThat( + isFullScreenCompactPhoneWindow( + widthDp = 738.5.dp, + heightDp = 361.5.dp, + smallestScreenWidthDp = 360, + screenWidthDp = 740, + screenHeightDp = 360, + isInMultiWindowMode = false + ) + ).isTrue() + + assertThat( + isFullScreenCompactPhoneWindow( + widthDp = 360.dp, + heightDp = 740.dp, + smallestScreenWidthDp = 360, + screenWidthDp = 360, + screenHeightDp = 740, + isInMultiWindowMode = true + ) + ).isFalse() + + assertThat( + isFullScreenCompactPhoneWindow( + widthDp = 800.dp, + heightDp = 400.dp, + smallestScreenWidthDp = 600, + screenWidthDp = 800, + screenHeightDp = 400, + isInMultiWindowMode = false + ) + ).isFalse() + + assertThat( + isFullScreenCompactPhoneWindow( + widthDp = 360.dp, + heightDp = 360.dp, + smallestScreenWidthDp = 360, + screenWidthDp = 360, + screenHeightDp = 360, + isInMultiWindowMode = false + ) + ).isFalse() + } +} diff --git a/ui/debug/src/main/java/com/google/jetpackcamera/ui/debug/DebugOverlayComponents.kt b/ui/debug/src/main/java/com/google/jetpackcamera/ui/debug/DebugOverlayComponents.kt index de8ffc2c2f..cfcfb7ae6f 100644 --- a/ui/debug/src/main/java/com/google/jetpackcamera/ui/debug/DebugOverlayComponents.kt +++ b/ui/debug/src/main/java/com/google/jetpackcamera/ui/debug/DebugOverlayComponents.kt @@ -155,7 +155,8 @@ fun DebugOverlay( onChangeZoomRatio: (Float) -> Unit, debugUiState: DebugUiState.Enabled, vararg extraControls: @Composable () -> Unit, - debugController: DebugController + debugController: DebugController, + cameraLayoutMode: String? = null ) { Box(modifier = modifier.fillMaxSize()) { Column(modifier = Modifier.padding(top = 100.dp)) { @@ -167,7 +168,8 @@ fun DebugOverlay( DebugConsole( debugUiState = debugUiState, onToggleDebugOverlay = debugController::toggleDebugOverlay, - extraControls = extraControls + extraControls = extraControls, + cameraLayoutMode = cameraLayoutMode ) } } @@ -200,7 +202,8 @@ private fun DebugConsole( debugUiState: DebugUiState.Enabled, onToggleDebugOverlay: () -> Unit, vararg extraControls: @Composable () -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + cameraLayoutMode: String? = null ) { Column(modifier = modifier) { FlowRow( @@ -217,6 +220,13 @@ private fun DebugConsole( modifier = Modifier, primaryZoomRatio = debugUiState.currentPrimaryZoomRatio ) + cameraLayoutMode?.let { mode -> + DebugTextBar( + title = stringResource(R.string.debug_text_layout_mode_prefix), + value = mode, + tag = DEBUG_LAYOUT_MODE_TAG + ) + } } } diff --git a/ui/debug/src/main/java/com/google/jetpackcamera/ui/debug/TestTags.kt b/ui/debug/src/main/java/com/google/jetpackcamera/ui/debug/TestTags.kt index a4edfcc2dd..5aff319752 100644 --- a/ui/debug/src/main/java/com/google/jetpackcamera/ui/debug/TestTags.kt +++ b/ui/debug/src/main/java/com/google/jetpackcamera/ui/debug/TestTags.kt @@ -27,3 +27,4 @@ const val DEBUG_OVERLAY_CAMERA_PROPERTIES_TAG = "DebugOverlayCameraPropertiesTag const val DEBUG_OVERLAY_SET_ZOOM_RATIO_TEXT_FIELD = "DebugOverlaySetZoomRatioTextField" const val DEBUG_OVERLAY_SET_ZOOM_RATIO_SET_BUTTON = "DebugOverlaySetZoomRatioSetButton" const val DEBUG_OVERLAY_VIDEO_RESOLUTION_TAG = "DebugOverlayVideoResolutionTag" +const val DEBUG_LAYOUT_MODE_TAG = "text_debug_layout_mode" diff --git a/ui/debug/src/main/res/values/strings.xml b/ui/debug/src/main/res/values/strings.xml index c7084c4b4b..27a7965149 100644 --- a/ui/debug/src/main/res/values/strings.xml +++ b/ui/debug/src/main/res/values/strings.xml @@ -18,6 +18,7 @@ "Physical ID: " "Logical ID: " + "Layout Mode: " Debug Hiding components From 211a93408f4360fca4795200a5768c1683faca32 Mon Sep 17 00:00:00 2001 From: Kimberly Crevecoeur Date: Thu, 12 Nov 2026 07:38:44 -0800 Subject: [PATCH 2/3] Wire real CaptureScaffold slots into side-rail layouts --- .../ui/components/capture/CaptureLayout.kt | 108 +++--------- .../ui/components/capture/CaptureScaffold.kt | 155 +++++++++++++++--- .../capture/SideRailCaptureLayouts.kt | 11 +- .../capture/AdaptiveCaptureLayoutTest.kt | 92 ++++++++++- 4 files changed, 255 insertions(+), 111 deletions(-) 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 8f1158fe74..e8295f9298 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 @@ -16,7 +16,6 @@ package com.google.jetpackcamera.ui.components.capture import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.aspectRatio @@ -24,7 +23,6 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.darkColorScheme @@ -35,7 +33,6 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.HorizontalAlignmentLine -import androidx.compose.ui.semantics.Role import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.google.jetpackcamera.ui.components.capture.layout.CameraLayoutMode @@ -198,30 +195,31 @@ internal fun CaptureScaffoldLayout( ) CompositionLocalProvider(LocalCameraLayoutMode provides layoutMode) { + val resolvedSlots = slots ?: CaptureSlots( + topBar = indicatorRow, + topBarTrailingContent = topBarTrailingContent, + zoomControls = zoomLevelDisplay, + quickSettingsButton = quickSettingsButton, + captureButton = captureButton, + flipCameraButton = flipCameraButton, + imageWell = imageWell, + captureModeToggle = captureModeToggle, + captureModeCarousel = captureModeCarousel, + elapsedTimeDisplay = elapsedTimeDisplay, + topStartContent = topStartContent, + viewfinderTopEndContent = viewfinderTopEndContent, + quickSettingsOverlay = { slotModifier -> + quickSettingsOverlay(slotModifier) + }, + snackBar = snackBar, + errorDialog = errorDialog + ) + when (layoutMode) { CameraLayoutMode.COMPACT, CameraLayoutMode.COMPACT_SLIM -> { - val compactSlots = slots ?: CaptureSlots( - topBar = indicatorRow, - topBarTrailingContent = topBarTrailingContent, - zoomControls = zoomLevelDisplay, - quickSettingsButton = quickSettingsButton, - captureButton = captureButton, - flipCameraButton = flipCameraButton, - imageWell = imageWell, - captureModeToggle = captureModeToggle, - captureModeCarousel = captureModeCarousel, - elapsedTimeDisplay = elapsedTimeDisplay, - topStartContent = topStartContent, - viewfinderTopEndContent = viewfinderTopEndContent, - quickSettingsOverlay = { slotModifier -> - quickSettingsOverlay(slotModifier) - }, - snackBar = snackBar, - errorDialog = errorDialog - ) CompactPortraitLayout( - slots = compactSlots, + slots = resolvedSlots, viewfinder = movableViewfinder, state = state, controlsVisible = controlsVisible, @@ -235,14 +233,8 @@ internal fun CaptureScaffoldLayout( } CameraLayoutMode.FOLDABLE -> { - val railSlots = slots ?: rememberPlaceholderRailSlots( - isCompactHeight = false, - state = state, - snackBar = snackBar, - errorDialog = errorDialog - ) FoldableLayout( - slots = railSlots, + slots = resolvedSlots, viewfinder = movableViewfinder, state = state, controlsVisible = controlsVisible, @@ -254,14 +246,8 @@ internal fun CaptureScaffoldLayout( } CameraLayoutMode.TABLET_PORTRAIT -> { - val railSlots = slots ?: rememberPlaceholderRailSlots( - isCompactHeight = false, - state = state, - snackBar = snackBar, - errorDialog = errorDialog - ) TabletPortraitLayout( - slots = railSlots, + slots = resolvedSlots, viewfinder = movableViewfinder, state = state, controlsVisible = controlsVisible, @@ -273,14 +259,8 @@ internal fun CaptureScaffoldLayout( } CameraLayoutMode.TABLET_LANDSCAPE -> { - val railSlots = slots ?: rememberPlaceholderRailSlots( - isCompactHeight = false, - state = state, - snackBar = snackBar, - errorDialog = errorDialog - ) TabletLandscapeLayout( - slots = railSlots, + slots = resolvedSlots, viewfinder = movableViewfinder, state = state, controlsVisible = controlsVisible, @@ -293,14 +273,8 @@ internal fun CaptureScaffoldLayout( CameraLayoutMode.COMPACT_SHORT, CameraLayoutMode.COMPACT_SHORT_SLIM -> { - val railSlots = slots ?: rememberPlaceholderRailSlots( - isCompactHeight = true, - state = state, - snackBar = snackBar, - errorDialog = errorDialog - ) CompactShortLayout( - slots = railSlots, + slots = resolvedSlots, viewfinder = movableViewfinder, state = state, controlsVisible = controlsVisible, @@ -315,38 +289,6 @@ internal fun CaptureScaffoldLayout( } } -// TODO(#635, #644, #648, #649): Replace placeholder side-rail slots with real CaptureScaffold -// slots once Wave 1 adaptive controls merge into main. -@Composable -private fun rememberPlaceholderRailSlots( - isCompactHeight: Boolean, - state: CaptureScaffoldState, - snackBar: @Composable (Modifier, SnackbarHostState) -> Unit, - errorDialog: @Composable (Modifier) -> Unit -): CaptureSlots = remember(isCompactHeight, state, snackBar, errorDialog) { - PlaceholderCaptureSlots.sideRail( - isCompactHeight = isCompactHeight, - tagQuickSettingsOverlay = true - ).copy( - quickSettingsButton = { slotModifier -> - PlaceholderControlBox( - label = "QS", - width = CaptureSlotTokens.IconTouchTargetSize, - height = CaptureSlotTokens.IconTouchTargetSize, - modifier = slotModifier.clickable( - role = Role.Button, - onClick = state::toggleQuickSettings - ), - color = CaptureSlotTokens.QuickSettingsButtonColor, - shape = CircleShape, - testTag = QUICK_SETTINGS_DROP_DOWN - ) - }, - snackBar = snackBar, - errorDialog = errorDialog - ) -} - @PreviewPortraitDevices @Composable private fun CaptureLayoutPreview() { 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 7549f04e0f..c620f48445 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 @@ -25,9 +25,11 @@ import androidx.compose.animation.ExitTransition import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.darkColorScheme @@ -51,7 +53,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp import com.google.jetpackcamera.core.camera.AudioStreamState import com.google.jetpackcamera.core.camera.InitialRecordingSettings import com.google.jetpackcamera.core.camera.VideoRecordingState @@ -61,6 +65,7 @@ import com.google.jetpackcamera.model.CaptureMode 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 @@ -71,12 +76,15 @@ import com.google.jetpackcamera.ui.controller.ScreenFlashController import com.google.jetpackcamera.ui.controller.SnackBarController import com.google.jetpackcamera.ui.controller.ZoomController import com.google.jetpackcamera.ui.controller.quicksettings.QuickSettingsController +import com.google.jetpackcamera.ui.uistate.DisableRationale import com.google.jetpackcamera.ui.uistate.SingleSelectableUiState import com.google.jetpackcamera.ui.uistate.SnackBarUiState import com.google.jetpackcamera.ui.uistate.capture.AudioUiState import com.google.jetpackcamera.ui.uistate.capture.CameraErrorUiState import com.google.jetpackcamera.ui.uistate.capture.CaptureButtonUiState import com.google.jetpackcamera.ui.uistate.capture.CaptureModeToggleUiState +import com.google.jetpackcamera.ui.uistate.capture.CaptureModeToggleUiState.Unavailable.findSelectableStateFor +import com.google.jetpackcamera.ui.uistate.capture.CaptureModeToggleUiState.Unavailable.isCaptureModeSelectable import com.google.jetpackcamera.ui.uistate.capture.CaptureSubModeUiState import com.google.jetpackcamera.ui.uistate.capture.ElapsedTimeUiState import com.google.jetpackcamera.ui.uistate.capture.FlipLensUiState @@ -498,12 +506,14 @@ object CaptureScaffoldDefaults { fun ZoomControls( uiState: ZoomControlUiState, onChangeZoom: (Float) -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + orientation: Orientation = Orientation.Horizontal ) { Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) { ZoomBar( zoomControlUiState = uiState, - onChangeZoom = onChangeZoom + onChangeZoom = onChangeZoom, + orientation = orientation ) } } @@ -518,19 +528,75 @@ object CaptureScaffoldDefaults { isRecording: Boolean, quickSettingsController: QuickSettingsController?, snackBarController: SnackBarController?, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + orientation: Orientation = Orientation.Horizontal, + isCompact: Boolean = false ) { if (!isRecording && uiState is CaptureModeToggleUiState.Available) { - CaptureModeToggleButton( - uiState = uiState, - onChangeCaptureMode = { newCaptureMode -> - quickSettingsController?.setCaptureMode(newCaptureMode) - }, - onToggleWhenDisabled = { disableRationale -> - snackBarController?.enqueueDisabledHdrToggleSnackBar(disableRationale) - }, - modifier = modifier.testTag(CAPTURE_MODE_TOGGLE_BUTTON) - ) + // TODO(#635): Replace vertical placeholder with vertical CaptureModeToggleButton. + if (orientation == Orientation.Vertical) { + val enabled = uiState.isCaptureModeSelectable(CaptureMode.VIDEO_ONLY) && + uiState.isCaptureModeSelectable(CaptureMode.IMAGE_ONLY) && + uiState.selectedCaptureMode != CaptureMode.STANDARD + val placeholderLabel = if (uiState.selectedCaptureMode == CaptureMode.VIDEO_ONLY) { + "Video" + } else { + "Photo" + } + PlaceholderControlBox( + label = placeholderLabel, + width = if (isCompact) { + CaptureSlotTokens.ModeToggleCompactHeight + } else { + CaptureSlotTokens.ModeToggleStandardHeight + }, + height = if (isCompact) { + CaptureSlotTokens.ModeToggleCompactWidth + } else { + CaptureSlotTokens.ModeToggleStandardWidth + }, + modifier = modifier.clickable( + role = Role.Switch, + onClick = { + if (enabled) { + val nextMode = + if (uiState.selectedCaptureMode == CaptureMode.VIDEO_ONLY) { + CaptureMode.IMAGE_ONLY + } else { + CaptureMode.VIDEO_ONLY + } + quickSettingsController?.setCaptureMode(nextMode) + } else { + val disabledReason: DisableRationale? = + ( + uiState.findSelectableStateFor(CaptureMode.VIDEO_ONLY) as? + SingleSelectableUiState.Disabled + )?.disabledReason + ?: ( + uiState.findSelectableStateFor(CaptureMode.IMAGE_ONLY) + as? SingleSelectableUiState.Disabled + )?.disabledReason + disabledReason?.let { + snackBarController?.enqueueDisabledHdrToggleSnackBar(it) + } + } + } + ), + color = CaptureSlotTokens.ModeToggleColor, + testTag = CAPTURE_MODE_TOGGLE_BUTTON + ) + } else { + CaptureModeToggleButton( + uiState = uiState, + onChangeCaptureMode = { newCaptureMode -> + quickSettingsController?.setCaptureMode(newCaptureMode) + }, + onToggleWhenDisabled = { disableRationale -> + snackBarController?.enqueueDisabledHdrToggleSnackBar(disableRationale) + }, + modifier = modifier.testTag(CAPTURE_MODE_TOGGLE_BUTTON) + ) + } } } @@ -616,7 +682,28 @@ object CaptureScaffoldDefaults { * Default top status indicator pill reading slices from a deferred [uiState] provider. */ @Composable - fun TopBar(uiState: () -> CaptureUiState.Ready, modifier: Modifier = Modifier) { + fun TopBar( + uiState: () -> CaptureUiState.Ready, + modifier: Modifier = Modifier, + orientation: Orientation = Orientation.Horizontal, + isCompact: Boolean = false + ) { + // TODO(#649): Replace vertical placeholder with VerticalIndicatorPill once #649 merges. + if (orientation == Orientation.Vertical) { + PlaceholderControlBox( + label = "Ind", + width = CaptureSlotTokens.IndicatorCrossAxisSize, + height = if (isCompact) { + CaptureSlotTokens.IndicatorCompactMainAxisSize + } else { + CaptureSlotTokens.IndicatorStandardMainAxisSize + }, + modifier = modifier, + color = CaptureSlotTokens.IndicatorColor, + testTag = VIDEO_QUALITY_TAG + ) + return + } val currentUiState by rememberUpdatedState(uiState) val flashModeState = remember { derivedStateOf { currentUiState().flashModeUiState } } val hdrState = remember { derivedStateOf { currentUiState().hdrUiState } } @@ -647,13 +734,25 @@ object CaptureScaffoldDefaults { showMoreSettingsButton: Boolean = true ) { if (controller != null) { - QuickSettingsScaffoldContent( - quickSettingsUiState = uiState, - onNavigateToSettings = onNavigateToSettings, - quickSettingsController = controller, - modifier = modifier, - showMoreSettingsButton = showMoreSettingsButton - ) + 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 + ) + } } } @@ -813,7 +912,12 @@ fun CaptureScaffold( snackBarController: SnackBarController? = null, screenFlashController: ScreenFlashController? = null, topBar: @Composable CaptureSlotScope.(Modifier) -> Unit = { slotModifier -> - CaptureScaffoldDefaults.TopBar(uiState = captureUiState, modifier = slotModifier) + CaptureScaffoldDefaults.TopBar( + uiState = captureUiState, + modifier = slotModifier, + orientation = orientation, + isCompact = isCompact + ) }, captureButton: @Composable CaptureSlotScope.(Modifier) -> Unit = { slotModifier -> val currentUiState by rememberUpdatedState(captureUiState) @@ -845,7 +949,8 @@ fun CaptureScaffold( CaptureScaffoldDefaults.ZoomControls( uiState = zoomControlState.value, onChangeZoom = ::animateZoomTo, - modifier = slotModifier + modifier = slotModifier, + orientation = orientation ) }, captureModeToggle: @Composable CaptureSlotScope.(Modifier) -> Unit = { slotModifier -> @@ -858,7 +963,9 @@ fun CaptureScaffold( isRecording = isRecording, quickSettingsController = quickSettingsController, snackBarController = snackBarController, - modifier = slotModifier + modifier = slotModifier, + orientation = orientation, + isCompact = isCompact ) }, captureModeCarousel: @Composable CaptureSlotScope.(Modifier) -> Unit = { slotModifier -> 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 21e1a0481a..a2865363cf 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 @@ -416,10 +416,19 @@ internal fun SideRailCaptureScaffold( ) } + val carouselHorizontalClearance = if (config.clearRailsForQuickSettings) { + topOverlayClearance + } else { + 0.dp + } OverlapAwareCaptureSlot( modifier = Modifier .align(Alignment.BottomCenter) - .padding(bottom = config.hudVerticalPadding) + .padding( + start = carouselHorizontalClearance, + end = carouselHorizontalClearance, + bottom = config.hudVerticalPadding + ) .suppressBackgroundForModal(isModalOpen), orientation = Orientation.Horizontal, isCompact = config.isCompactHeight, diff --git a/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/AdaptiveCaptureLayoutTest.kt b/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/AdaptiveCaptureLayoutTest.kt index fad557c6d7..a37c040ef4 100644 --- a/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/AdaptiveCaptureLayoutTest.kt +++ b/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/AdaptiveCaptureLayoutTest.kt @@ -40,8 +40,24 @@ import androidx.core.graphics.Insets import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import com.google.common.truth.Truth.assertThat +import com.google.jetpackcamera.model.CaptureMode +import com.google.jetpackcamera.model.CaptureSubModeId +import com.google.jetpackcamera.model.LensFacing import com.google.jetpackcamera.ui.components.capture.layout.CameraLayoutMode import com.google.jetpackcamera.ui.components.capture.layout.LocalCameraLayoutMode +import com.google.jetpackcamera.ui.controller.testing.FakeQuickSettingsController +import com.google.jetpackcamera.ui.uistate.SingleSelectableUiState +import com.google.jetpackcamera.ui.uistate.capture.AspectRatioUiState +import com.google.jetpackcamera.ui.uistate.capture.CaptureButtonUiState +import com.google.jetpackcamera.ui.uistate.capture.CaptureModeToggleUiState +import com.google.jetpackcamera.ui.uistate.capture.CaptureModeUiState +import com.google.jetpackcamera.ui.uistate.capture.CaptureSubModeOption +import com.google.jetpackcamera.ui.uistate.capture.CaptureSubModeUiState +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.ZoomControlUiState +import com.google.jetpackcamera.ui.uistate.capture.compound.QuickSettingsUiState import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -311,18 +327,71 @@ class AdaptiveCaptureLayoutTest { @Config(qualifiers = "sw800dp-w1280dp-h800dp-land-mdpi") @Test - fun tabletLandscape_resolvesToTabletLandscapeLayoutWithInteractivePlaceholderRailSlots() { + fun tabletLandscape_resolvesToTabletLandscapeLayoutWithRealControlsAndVerticalPlaceholders() { val state = CaptureScaffoldState(isInitiallyExpanded = false) + var selectedCaptureMode: CaptureMode? = null + val quickSettingsController = FakeQuickSettingsController( + setCaptureModeAction = { selectedCaptureMode = it } + ) var observedMode: CameraLayoutMode? = null var observedLayout: String? = null + val readyUiState = LOADING_CAPTURE_UI_STATE_SKELETON.copy( + captureButtonUiState = CaptureButtonUiState.Enabled.Idle( + captureMode = CaptureMode.IMAGE_ONLY, + isEnabled = true + ), + flipLensUiState = FlipLensUiState.Available( + selectedLensFacing = LensFacing.BACK, + availableLensFacings = listOf( + SingleSelectableUiState.SelectableUi(LensFacing.BACK), + SingleSelectableUiState.SelectableUi(LensFacing.FRONT) + ) + ), + zoomControlUiState = ZoomControlUiState.Enabled( + zoomLevels = listOf(0.5f, 1.0f, 2.0f), + primaryLensFacing = LensFacing.BACK, + initialZoomRatio = 1.0f, + primaryZoomRatio = 1.0f + ), + captureModeToggleUiState = CaptureModeToggleUiState.Available( + selectedCaptureMode = CaptureMode.IMAGE_ONLY, + imageOnlyUiState = SingleSelectableUiState.SelectableUi(CaptureMode.IMAGE_ONLY), + videoOnlyUiState = SingleSelectableUiState.SelectableUi(CaptureMode.VIDEO_ONLY) + ), + captureSubModeUiState = CaptureSubModeUiState.Available( + selectedSubMode = CaptureSubModeId.DEFAULT, + availableSubModes = listOf( + SingleSelectableUiState.SelectableUi( + CaptureSubModeOption( + id = CaptureSubModeId.DEFAULT, + labelResId = R.string.quick_settings_text_capture_mode_image_only + ) + ), + SingleSelectableUiState.SelectableUi( + CaptureSubModeOption( + id = CaptureSubModeId("video"), + labelResId = R.string.quick_settings_text_capture_mode_video_only + ) + ) + ) + ), + quickSettingsUiState = QuickSettingsUiState.Available( + aspectRatioUiState = AspectRatioUiState.Unavailable, + captureModeUiState = CaptureModeUiState.Unavailable, + flashModeUiState = FlashModeUiState.Unavailable, + flipLensUiState = FlipLensUiState.Unavailable, + hdrUiState = HdrUiState.Unavailable + ) + ) composeTestRule.setContent { MaterialTheme { CompositionLocalProvider(LocalDisableAnimations provides true) { CaptureScaffold( - captureUiState = { LOADING_CAPTURE_UI_STATE_SKELETON }, + captureUiState = { readyUiState }, surfaceRequest = null, state = state, + quickSettingsController = quickSettingsController, overlay = { observedMode = LocalCameraLayoutMode.current observedLayout = LocalActiveCaptureLayout.current @@ -339,13 +408,30 @@ class AdaptiveCaptureLayoutTest { CameraLayoutMode.TABLET_LANDSCAPE, "TabletLandscapeLayout" ) + // Real controls are placed directly into the side rails and bottom HUD. composeTestRule.onNodeWithTag(CAPTURE_BUTTON).assertIsDisplayed() + composeTestRule.onNodeWithTag(FLIP_CAMERA_BUTTON).assertIsDisplayed() + composeTestRule.onNodeWithTag(CAPTURE_MODE_CAROUSEL).assertIsDisplayed() + + // Real ZoomBar renders vertically inside StartSideRail (height strictly exceeds width). + val zoomBounds = composeTestRule.onNodeWithTag( + ZOOM_BUTTON_ROW_TAG + ).getUnclippedBoundsInRoot() + assertThat(zoomBounds.bottom - zoomBounds.top) + .isGreaterThan(zoomBounds.right - zoomBounds.left) + + // Vertical placeholders stand in cleanly for TopBar (#649) and CaptureModeToggle (#635). + composeTestRule.onNodeWithTag(VIDEO_QUALITY_TAG).assertIsDisplayed() + composeTestRule.onNodeWithTag(CAPTURE_MODE_TOGGLE_BUTTON).assertIsDisplayed().performClick() + composeTestRule.waitForIdle() + assertThat(selectedCaptureMode).isEqualTo(CaptureMode.VIDEO_ONLY) + composeTestRule .onNodeWithTag(QUICK_SETTINGS_OVERLAY_CARD_PLACEHOLDER_TAG) .assertDoesNotExist() composeTestRule.onNodeWithTag(QUICK_SETTINGS_BOTTOM_SHEET).assertDoesNotExist() - // Clicking the interactive placeholder "QS" button toggles state.isQuickSettingsOpen. + // Clicking real ToggleQuickSettingsButton opens QuickSettingsContent inside the card shell. composeTestRule.onNodeWithTag(QUICK_SETTINGS_DROP_DOWN).performClick() composeTestRule.waitForIdle() From da81b4b441900ad97098ba60da4534d5fb7a82b4 Mon Sep 17 00:00:00 2001 From: Kimberly Crevecoeur Date: Fri, 13 Nov 2026 08:52:11 -0800 Subject: [PATCH 3/3] refactor(ui/capture): forward isErrorDialogShowing and use placeRelative in ConstrainedSlot --- .../jetpackcamera/ui/components/capture/CaptureScaffold.kt | 7 +++++++ .../ui/components/capture/CompactShortLayout.kt | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) 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 c620f48445..b79068df11 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 @@ -1240,9 +1240,16 @@ fun CaptureScaffold( ) } + val isErrorDialogShowing by remember { + derivedStateOf { + cameraErrorState.value is CameraErrorUiState.Showing && errorDialog != null + } + } + CaptureScaffoldLayout( modifier = modifier, state = state, + isErrorDialogShowing = isErrorDialogShowing, onDismissQuickSettings = onDismissQuickSettings, viewfinder = viewfinderLambda, captureButton = { slotModifier -> diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CompactShortLayout.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CompactShortLayout.kt index 32801fbf65..00193bdd67 100644 --- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CompactShortLayout.kt +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CompactShortLayout.kt @@ -342,7 +342,7 @@ private fun PreviewOverlaySlotScope.ConstrainedSlot( .coerceIn(constraints.minHeight, constraints.maxHeight) layout(resolvedWidth, resolvedHeight) { placeables.forEach { placeable -> - placeable.place( + placeable.placeRelative( x = (resolvedWidth - placeable.width) / 2, y = (resolvedHeight - placeable.height) / 2 )