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 3e18fb39db..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 @@ -17,7 +17,9 @@ 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 @@ -25,10 +27,18 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.darkColorScheme 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 /** * The top edge of the visible viewfinder, reported by a viewfinder that fills its slot and offsets @@ -41,13 +51,18 @@ import androidx.compose.ui.unit.dp internal val ViewfinderTopLine = HorizontalAlignmentLine { a, b -> minOf(a, b) } /** - * The base adaptive layout for the camera capture screen. + * Adaptive 7-mode orchestrator layout for the camera capture screen. * - * Positions the [viewfinder], individual control slots ([captureButton], [imageWell], - * [flipCameraButton], [zoomLevelDisplay], [elapsedTimeDisplay], [quickSettingsButton], - * [indicatorRow], [captureModeToggle], [captureModeCarousel]), [quickSettingsOverlay], - * [topStartContent], [viewfinderTopEndContent], [topBarTrailingContent], [overlay], - * [screenFlashOverlay], [snackBar], and [errorDialog] without depending on [CaptureUiState]. + * Evaluates the available container geometry via [rememberCameraLayoutMode] with `14.dp` (`±7.dp`) + * hysteresis deadbands, provides the resolved [CameraLayoutMode] via [LocalCameraLayoutMode], wraps + * [viewfinder] in [movableContentOf] so the live camera surface is reparented across posture + * transitions without disposal, and dispatches across the 5 dedicated posture layouts: + * - [CameraLayoutMode.COMPACT] and [CameraLayoutMode.COMPACT_SLIM] -> [CompactPortraitLayout] + * - [CameraLayoutMode.COMPACT_SHORT] and [CameraLayoutMode.COMPACT_SHORT_SLIM] -> + * [CompactShortLayout] + * - [CameraLayoutMode.FOLDABLE] -> [FoldableLayout] + * - [CameraLayoutMode.TABLET_PORTRAIT] -> [TabletPortraitLayout] + * - [CameraLayoutMode.TABLET_LANDSCAPE] -> [TabletLandscapeLayout] * * @param modifier the [Modifier] for this component. * @param state the mode-agnostic [CaptureScaffoldState] controlling Quick Settings visibility and @@ -61,10 +76,10 @@ internal val ViewfinderTopLine = HorizontalAlignmentLine { a, b -> minOf(a, b) } * @param zoomLevelDisplay the zoom level display composable. * @param elapsedTimeDisplay the elapsed time display composable. * @param quickSettingsButton the quick settings button composable. - * @param indicatorRow the top indicator row composable. + * @param indicatorRow the status indicator row/column composable. * @param captureModeToggle the capture mode toggle composable. * @param captureModeCarousel content for the fixed-height slot reserved between the shutter row - * and the lower controls row. + * and the lower controls row on compact layouts, or the bottom-center HUD on side-rail layouts. * @param quickSettingsOverlay the quick settings overlay composable. * @param overlay optional overlay composable drawn above the viewfinder and controls. * @param controlsVisible whether the camera controls should be composed and visible. @@ -105,37 +120,175 @@ fun CaptureScaffoldLayout( onRequiresImmersiveChange: (Boolean) -> Unit = {}, errorDialog: @Composable (Modifier) -> Unit = {} ) { - val slots = CaptureSlots( - topBar = indicatorRow, - topBarTrailingContent = topBarTrailingContent, - zoomControls = zoomLevelDisplay, - quickSettingsButton = quickSettingsButton, + CaptureScaffoldLayout( + modifier = modifier, + state = state, + onDismissQuickSettings = onDismissQuickSettings, + viewfinder = viewfinder, captureButton = captureButton, - flipCameraButton = flipCameraButton, imageWell = imageWell, + flipCameraButton = flipCameraButton, + zoomLevelDisplay = zoomLevelDisplay, + elapsedTimeDisplay = elapsedTimeDisplay, + quickSettingsButton = quickSettingsButton, + indicatorRow = indicatorRow, captureModeToggle = captureModeToggle, captureModeCarousel = captureModeCarousel, - elapsedTimeDisplay = elapsedTimeDisplay, + quickSettingsOverlay = quickSettingsOverlay, + overlay = overlay, + controlsVisible = controlsVisible, + screenFlashOverlay = screenFlashOverlay, + snackBar = snackBar, topStartContent = topStartContent, viewfinderTopEndContent = viewfinderTopEndContent, - quickSettingsOverlay = { slotModifier -> quickSettingsOverlay(slotModifier) }, - snackBar = snackBar, - errorDialog = errorDialog - ) - CompactPortraitLayout( - slots = slots, - viewfinder = viewfinder, - modifier = modifier, - state = state, - controlsVisible = controlsVisible, - includeStatusBarsInTopBar = false, - onDismissQuickSettings = onDismissQuickSettings, + topBarTrailingContent = topBarTrailingContent, onRequiresImmersiveChange = onRequiresImmersiveChange, - overlay = overlay, - screenFlashOverlay = screenFlashOverlay + errorDialog = errorDialog, + slots = null ) } +@Composable +internal fun CaptureScaffoldLayout( + modifier: Modifier = Modifier, + state: CaptureScaffoldState = rememberCaptureScaffoldState(), + onDismissQuickSettings: () -> Unit = {}, + viewfinder: @Composable (Modifier) -> Unit, + captureButton: @Composable PreviewOverlaySlotScope.(Modifier) -> Unit = {}, + imageWell: @Composable PreviewOverlaySlotScope.(Modifier) -> Unit = {}, + flipCameraButton: @Composable PreviewOverlaySlotScope.(Modifier) -> Unit = {}, + zoomLevelDisplay: @Composable PreviewOverlaySlotScope.(Modifier) -> Unit = {}, + elapsedTimeDisplay: @Composable PreviewOverlaySlotScope.(Modifier) -> Unit = {}, + quickSettingsButton: @Composable PreviewOverlaySlotScope.(Modifier) -> Unit = {}, + indicatorRow: @Composable PreviewOverlaySlotScope.(Modifier) -> Unit = {}, + captureModeToggle: @Composable PreviewOverlaySlotScope.(Modifier) -> Unit = {}, + captureModeCarousel: @Composable PreviewOverlaySlotScope.(Modifier) -> Unit = {}, + quickSettingsOverlay: @Composable (Modifier) -> Unit = {}, + overlay: @Composable PreviewOverlaySlotScope.(Modifier) -> Unit = {}, + controlsVisible: Boolean = true, + screenFlashOverlay: @Composable (Modifier) -> Unit = {}, + snackBar: @Composable (Modifier, snackbarHostState: SnackbarHostState) -> Unit = { _, _ -> }, + topStartContent: @Composable PreviewOverlaySlotScope.(Modifier) -> Unit = {}, + viewfinderTopEndContent: @Composable PreviewOverlaySlotScope.(Modifier) -> Unit = {}, + topBarTrailingContent: @Composable PreviewOverlaySlotScope.(Modifier) -> Unit = {}, + onRequiresImmersiveChange: (Boolean) -> Unit = {}, + errorDialog: @Composable (Modifier) -> Unit = {}, + isErrorDialogShowing: Boolean = false, + slots: CaptureSlots? = null +) { + 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 + ) + + 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 -> { + CompactPortraitLayout( + slots = resolvedSlots, + viewfinder = movableViewfinder, + state = state, + controlsVisible = controlsVisible, + isErrorDialogShowing = isErrorDialogShowing, + includeStatusBarsInTopBar = !isFullScreenCompactPhone, + onDismissQuickSettings = onDismissQuickSettings, + onRequiresImmersiveChange = onRequiresImmersiveChange, + overlay = overlay, + screenFlashOverlay = screenFlashOverlay + ) + } + + CameraLayoutMode.FOLDABLE -> { + FoldableLayout( + slots = resolvedSlots, + viewfinder = movableViewfinder, + state = state, + controlsVisible = controlsVisible, + isErrorDialogShowing = isErrorDialogShowing, + onDismissQuickSettings = onDismissQuickSettings, + overlay = overlay, + screenFlashOverlay = screenFlashOverlay + ) + } + + CameraLayoutMode.TABLET_PORTRAIT -> { + TabletPortraitLayout( + slots = resolvedSlots, + viewfinder = movableViewfinder, + state = state, + controlsVisible = controlsVisible, + isErrorDialogShowing = isErrorDialogShowing, + onDismissQuickSettings = onDismissQuickSettings, + overlay = overlay, + screenFlashOverlay = screenFlashOverlay + ) + } + + CameraLayoutMode.TABLET_LANDSCAPE -> { + TabletLandscapeLayout( + slots = resolvedSlots, + viewfinder = movableViewfinder, + state = state, + controlsVisible = controlsVisible, + isErrorDialogShowing = isErrorDialogShowing, + onDismissQuickSettings = onDismissQuickSettings, + overlay = overlay, + screenFlashOverlay = screenFlashOverlay + ) + } + + CameraLayoutMode.COMPACT_SHORT, + CameraLayoutMode.COMPACT_SHORT_SLIM -> { + CompactShortLayout( + slots = resolvedSlots, + viewfinder = movableViewfinder, + state = state, + controlsVisible = controlsVisible, + isErrorDialogShowing = isErrorDialogShowing, + onDismissQuickSettings = onDismissQuickSettings, + overlay = overlay, + screenFlashOverlay = screenFlashOverlay + ) + } + } + } + } +} + @PreviewPortraitDevices @Composable private fun CaptureLayoutPreview() { @@ -213,3 +366,144 @@ private fun CaptureLayoutPreview() { ) } } + +@Preview( + name = "1. COMPACT - Portrait (412x915dp)", + device = "spec:width=412dp,height=915dp", + showBackground = true +) +@Composable +private fun OrchestratorCompactPortraitPreview() { + MaterialTheme(colorScheme = darkColorScheme()) { + CaptureScaffoldLayout( + viewfinder = { mod -> PlaceholderViewfinder(modifier = mod) }, + slots = PlaceholderCaptureSlots.sideRail(includeElapsedTime = true) + ) + } +} + +@Preview( + name = "2. COMPACT - Horizontal (800x400dp)", + device = "spec:width=800dp,height=400dp", + showBackground = true +) +@Composable +private fun OrchestratorCompactHorizontalPreview() { + MaterialTheme(colorScheme = darkColorScheme()) { + CaptureScaffoldLayout( + viewfinder = { mod -> PlaceholderViewfinder(modifier = mod) }, + slots = PlaceholderCaptureSlots.sideRail(includeElapsedTime = true) + ) + } +} + +@Preview( + name = "3. COMPACT_SLIM - Portrait (320x740dp)", + device = "spec:width=320dp,height=740dp", + showBackground = true +) +@Composable +private fun OrchestratorCompactSlimPortraitPreview() { + MaterialTheme(colorScheme = darkColorScheme()) { + CaptureScaffoldLayout( + viewfinder = { mod -> PlaceholderViewfinder(modifier = mod) }, + slots = PlaceholderCaptureSlots.sideRail(includeElapsedTime = true) + ) + } +} + +@Preview( + name = "4. COMPACT_SLIM - Horizontal (740x320dp)", + device = "spec:width=740dp,height=320dp", + showBackground = true +) +@Composable +private fun OrchestratorCompactSlimHorizontalPreview() { + MaterialTheme(colorScheme = darkColorScheme()) { + CaptureScaffoldLayout( + viewfinder = { mod -> PlaceholderViewfinder(modifier = mod) }, + slots = PlaceholderCaptureSlots.sideRail(includeElapsedTime = true) + ) + } +} + +@Preview( + name = "5. COMPACT_SHORT - Square Split/Cover (360x360dp)", + device = "spec:width=360dp,height=360dp", + showBackground = true +) +@Composable +private fun OrchestratorCompactShortPreview() { + MaterialTheme(colorScheme = darkColorScheme()) { + CaptureScaffoldLayout( + viewfinder = { mod -> PlaceholderViewfinder(modifier = mod) }, + slots = PlaceholderCaptureSlots.sideRail( + isCompactHeight = true, + includeElapsedTime = true + ) + ) + } +} + +@Preview( + name = "6. COMPACT_SHORT_SLIM - Tiny Pane (320x340dp)", + device = "spec:width=320dp,height=340dp", + showBackground = true +) +@Composable +private fun OrchestratorCompactShortSlimPreview() { + MaterialTheme(colorScheme = darkColorScheme()) { + CaptureScaffoldLayout( + viewfinder = { mod -> PlaceholderViewfinder(modifier = mod) }, + slots = PlaceholderCaptureSlots.sideRail( + isCompactHeight = true, + includeElapsedTime = true + ) + ) + } +} + +@Preview( + name = "7. FOLDABLE - Unfolded Inner Display (852x883dp)", + device = "spec:width=852dp,height=883dp", + showBackground = true +) +@Composable +private fun OrchestratorFoldablePreview() { + MaterialTheme(colorScheme = darkColorScheme()) { + CaptureScaffoldLayout( + viewfinder = { mod -> PlaceholderViewfinder(modifier = mod) }, + slots = PlaceholderCaptureSlots.sideRail(includeElapsedTime = true) + ) + } +} + +@Preview( + name = "8. TABLET_PORTRAIT - Lower Thumb Zone (800x1280dp)", + device = "spec:width=800dp,height=1280dp", + showBackground = true +) +@Composable +private fun OrchestratorTabletPortraitPreview() { + MaterialTheme(colorScheme = darkColorScheme()) { + CaptureScaffoldLayout( + viewfinder = { mod -> PlaceholderViewfinder(modifier = mod) }, + slots = PlaceholderCaptureSlots.sideRail(includeElapsedTime = true) + ) + } +} + +@Preview( + name = "9. TABLET_LANDSCAPE - Generous Rails (1280x800dp)", + device = "spec:width=1280dp,height=800dp", + showBackground = true +) +@Composable +private fun OrchestratorTabletLandscapePreview() { + MaterialTheme(colorScheme = darkColorScheme()) { + CaptureScaffoldLayout( + viewfinder = { mod -> PlaceholderViewfinder(modifier = mod) }, + slots = PlaceholderCaptureSlots.sideRail(includeElapsedTime = true) + ) + } +} 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..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 @@ -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 -> @@ -1133,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 ) 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 8831e502bf..1e2526e75f 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 @@ -412,10 +412,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/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..127768f900 --- /dev/null +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/layout/CameraLayoutMode.kt @@ -0,0 +1,377 @@ +/* + * 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 FoldableMaxSpanBreakpoint = 960.dp + const val FOLDABLE_MAX_ASPECT_RATIO = 1.45f + 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 EnterFoldableSpanThreshold = FoldableMaxSpanBreakpoint - HysteresisHalfBand + val ExitFoldableSpanThreshold = FoldableMaxSpanBreakpoint + 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 7-mode structural layout classification for the camera capture screen based on + * available window width, height, aspect ratio, and multi-window posture. + */ +enum class CameraLayoutMode { + /** + * Standard compact posture used on full-screen compact phones (in both portrait and landscape + * orientations) and compact multi-window panes where `maxOf(widthDp, heightDp) >= 480.dp`, + * `minOf(widthDp, heightDp) >= 360.dp`, and not `widthDp >= 600.dp && heightDp >= 480.dp`. + * Hosted by `CompactPortraitLayout`. + */ + COMPACT, + + /** + * Narrow/slim compact posture (`minOf(widthDp, heightDp) < 360.dp` and + * `maxOf(widthDp, heightDp) >= 480.dp`, such as slim outer foldable displays or 1/3 + * split-screen panes). Hosted by `CompactPortraitLayout`. + */ + COMPACT_SLIM, + + /** + * Compact, short-on-both-axes posture (`widthDp < 480.dp` and `heightDp < 480.dp` with + * `minOf(widthDp, heightDp) >= 360.dp`, such as 50/50 phone split-screen panes or clamshell + * cover screens). Hosted by `CompactShortLayout`. + */ + COMPACT_SHORT, + + /** + * Ultra-compact slim and short posture (`widthDp < 480.dp`, `heightDp < 480.dp`, and + * `minOf(widthDp, heightDp) < 360.dp`, such as small cover screens or compact freeform + * windows). Hosted by `CompactShortLayout`. + */ + COMPACT_SHORT_SLIM, + + /** + * Unfolded book-style foldable inner display (`widthDp >= 600.dp`, `heightDp >= 480.dp`, + * `maxOf(widthDp, heightDp) < 960.dp`, and `maxOf(widthDp, heightDp) < minOf(widthDp, heightDp) + * * 1.45f`, such as `~673x841dp` to `~852x883dp`). Hosted by `FoldableLayout`. + */ + FOLDABLE, + + /** + * Portrait tablet posture (`widthDp >= 600.dp`, `heightDp >= 480.dp`, outside [FOLDABLE], and + * `heightDp > widthDp`, such as `~800x1280dp`). Hosted by `TabletPortraitLayout` with its lower + * ergonomic thumb zone (`0.70f`). + */ + TABLET_PORTRAIT, + + /** + * Landscape tablet posture (`widthDp >= 600.dp`, `heightDp >= 480.dp`, outside [FOLDABLE], and + * `widthDp >= heightDp`, such as `~1280x800dp`). Hosted by `TabletLandscapeLayout`. + */ + TABLET_LANDSCAPE +} + +/** + * CompositionLocal providing the active [CameraLayoutMode] resolved by `CaptureScaffoldLayout`. + * + * Defaults to [CameraLayoutMode.COMPACT] when read outside an adaptive orchestrator. + */ +val LocalCameraLayoutMode: ProvidableCompositionLocal = + compositionLocalOf { CameraLayoutMode.COMPACT } + +private val CameraLayoutMode.isSideRailLargeScreen: Boolean + get() = this == CameraLayoutMode.FOLDABLE || + this == CameraLayoutMode.TABLET_PORTRAIT || + this == CameraLayoutMode.TABLET_LANDSCAPE + +/** + * Classifies the capture window geometry into one of the 7 [CameraLayoutMode] values, applying a + * `14.dp` (`±7.dp`) hysteresis deadband around the `360.dp` slim (`[353.dp, 367.dp]`), `480.dp` + * short (`[473.dp, 487.dp]`), `600.dp` wide (`[593.dp, 607.dp]`), `960.dp` / `1.45f` foldable vs. + * tablet, and square tablet orientation boundaries 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 maxDim = maxOf(widthDp, heightDp) + + val isSlim = when (previousMode) { + null -> minDim < CameraLayoutModeTokens.SlimDimensionBreakpoint + CameraLayoutMode.COMPACT_SLIM, + CameraLayoutMode.COMPACT_SHORT_SLIM -> + minDim < CameraLayoutModeTokens.ExitSlimThreshold + CameraLayoutMode.COMPACT, + CameraLayoutMode.COMPACT_SHORT, + CameraLayoutMode.FOLDABLE, + CameraLayoutMode.TABLET_PORTRAIT, + CameraLayoutMode.TABLET_LANDSCAPE -> + minDim < CameraLayoutModeTokens.EnterSlimThreshold + } + + if (isFullScreenCompactPhone) { + return if (isSlim) CameraLayoutMode.COMPACT_SLIM else CameraLayoutMode.COMPACT + } + + val isWide600 = when { + previousMode == null -> widthDp >= CameraLayoutModeTokens.MediumWidthBreakpoint + previousMode.isSideRailLargeScreen -> + widthDp >= CameraLayoutModeTokens.ExitWide600Threshold + else -> widthDp >= CameraLayoutModeTokens.EnterWide600Threshold + } + + val isMaxAtLeast480 = when (previousMode) { + null -> maxDim >= CameraLayoutModeTokens.ShortDimensionBreakpoint + CameraLayoutMode.COMPACT_SHORT, + CameraLayoutMode.COMPACT_SHORT_SLIM -> + maxDim >= CameraLayoutModeTokens.EnterAtLeast480Threshold + CameraLayoutMode.COMPACT, + CameraLayoutMode.COMPACT_SLIM, + CameraLayoutMode.FOLDABLE, + CameraLayoutMode.TABLET_PORTRAIT, + CameraLayoutMode.TABLET_LANDSCAPE -> + maxDim >= CameraLayoutModeTokens.ExitAtLeast480Threshold + } + + val isHeightAtLeast480 = when { + previousMode == null -> heightDp >= CameraLayoutModeTokens.ShortDimensionBreakpoint + previousMode.isSideRailLargeScreen -> + heightDp >= CameraLayoutModeTokens.ExitAtLeast480Threshold + previousMode == CameraLayoutMode.COMPACT_SHORT || + previousMode == CameraLayoutMode.COMPACT_SHORT_SLIM -> + heightDp >= CameraLayoutModeTokens.EnterAtLeast480Threshold + previousMode == CameraLayoutMode.COMPACT || + previousMode == CameraLayoutMode.COMPACT_SLIM -> + if (heightDp >= widthDp) { + heightDp >= CameraLayoutModeTokens.ExitAtLeast480Threshold + } else { + heightDp >= CameraLayoutModeTokens.EnterAtLeast480Threshold + } + else -> heightDp >= CameraLayoutModeTokens.ShortDimensionBreakpoint + } + + if (isWide600 && isHeightAtLeast480) { + val isUnderFoldableSpan = when (previousMode) { + CameraLayoutMode.FOLDABLE -> + maxDim < CameraLayoutModeTokens.ExitFoldableSpanThreshold + CameraLayoutMode.TABLET_PORTRAIT, + CameraLayoutMode.TABLET_LANDSCAPE -> + maxDim < CameraLayoutModeTokens.EnterFoldableSpanThreshold + else -> maxDim < CameraLayoutModeTokens.FoldableMaxSpanBreakpoint + } + + val aspectLimit = minDim * CameraLayoutModeTokens.FOLDABLE_MAX_ASPECT_RATIO + val isSquarerFoldableAspect = when (previousMode) { + CameraLayoutMode.FOLDABLE -> + maxDim < aspectLimit + CameraLayoutModeTokens.HysteresisHalfBand + CameraLayoutMode.TABLET_PORTRAIT, + CameraLayoutMode.TABLET_LANDSCAPE -> + maxDim < aspectLimit - CameraLayoutModeTokens.HysteresisHalfBand + else -> maxDim < aspectLimit + } + + if (isUnderFoldableSpan && isSquarerFoldableAspect) { + return CameraLayoutMode.FOLDABLE + } + + return when (previousMode) { + CameraLayoutMode.TABLET_PORTRAIT -> + if (widthDp - heightDp >= CameraLayoutModeTokens.HysteresisHalfBand) { + CameraLayoutMode.TABLET_LANDSCAPE + } else { + CameraLayoutMode.TABLET_PORTRAIT + } + CameraLayoutMode.TABLET_LANDSCAPE -> + if (heightDp - widthDp >= CameraLayoutModeTokens.HysteresisHalfBand) { + CameraLayoutMode.TABLET_PORTRAIT + } else { + CameraLayoutMode.TABLET_LANDSCAPE + } + else -> + if (heightDp > widthDp) { + CameraLayoutMode.TABLET_PORTRAIT + } else { + CameraLayoutMode.TABLET_LANDSCAPE + } + } + } + + return when { + !isMaxAtLeast480 -> + if (isSlim) { + CameraLayoutMode.COMPACT_SHORT_SLIM + } else { + CameraLayoutMode.COMPACT_SHORT + } + isSlim -> CameraLayoutMode.COMPACT_SLIM + else -> CameraLayoutMode.COMPACT + } +} + +/** + * 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 + * deadbands prevent rapid oscillation near window-size and aspect-ratio boundaries. + * + * @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..a37c040ef4 --- /dev/null +++ b/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/AdaptiveCaptureLayoutTest.kt @@ -0,0 +1,664 @@ +/* + * 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.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +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.test.performClick +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.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 +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 [CaptureScaffoldLayout] and [CaptureScaffold] adaptive 7-mode + * orchestration across device qualifiers, [LocalActiveCaptureLayout] reporting, placeholder rail + * isolation, 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 OrchestratedLayoutUnderTest( + modifier: Modifier = Modifier, + state: CaptureScaffoldState = rememberCaptureScaffoldState(), + slots: CaptureSlots? = null, + onModeObserved: (CameraLayoutMode) -> Unit = {}, + onActiveLayoutObserved: (String) -> Unit = {}, + viewfinderContent: @Composable (Modifier) -> Unit = { vfMod -> + PlaceholderViewfinder(modifier = vfMod) + } + ) { + MaterialTheme { + CaptureScaffoldLayout( + modifier = modifier, + state = state, + viewfinder = { vfModifier -> + onModeObserved(LocalCameraLayoutMode.current) + onActiveLayoutObserved(LocalActiveCaptureLayout.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) + ) + }, + slots = slots + ) + } + } + + private fun assertCompactLayoutTopology( + observedMode: CameraLayoutMode?, + observedLayout: String?, + expectedMode: CameraLayoutMode = CameraLayoutMode.COMPACT + ) { + assertThat(observedMode).isEqualTo(expectedMode) + assertThat(observedLayout).isEqualTo("CompactPortraitLayout") + 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?, + observedLayout: String?, + expectedMode: CameraLayoutMode, + expectedLayout: String + ) { + assertThat(observedMode).isEqualTo(expectedMode) + assertThat(observedLayout).isEqualTo(expectedLayout) + 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_resolvesToCompactWithoutSideRails() { + var observedMode: CameraLayoutMode? = null + var observedLayout: String? = null + + composeTestRule.setContent { + OrchestratedLayoutUnderTest( + onModeObserved = { observedMode = it }, + onActiveLayoutObserved = { observedLayout = it } + ) + } + composeTestRule.waitForIdle() + + assertCompactLayoutTopology(observedMode, observedLayout, CameraLayoutMode.COMPACT) + } + + @Config(qualifiers = "sw360dp-w740dp-h360dp-normal-long-land-mdpi") + @Test + fun standardPhoneLandscapeFullScreen_sw360dp_w740dp_h360dp_resolvesToCompact() { + var observedMode: CameraLayoutMode? = null + var observedLayout: String? = null + + composeTestRule.setContent { + OrchestratedLayoutUnderTest( + onModeObserved = { observedMode = it }, + onActiveLayoutObserved = { observedLayout = it } + ) + } + composeTestRule.waitForIdle() + + assertCompactLayoutTopology(observedMode, observedLayout, CameraLayoutMode.COMPACT) + } + + @Config(qualifiers = "sw320dp-w320dp-h640dp-port-mdpi") + @Test + fun slimDisplay_sw320dp_w320dp_h640dp_resolvesToCompactSlimUsingCompactLayout() { + var observedMode: CameraLayoutMode? = null + var observedLayout: String? = null + + composeTestRule.setContent { + OrchestratedLayoutUnderTest( + onModeObserved = { observedMode = it }, + onActiveLayoutObserved = { observedLayout = it } + ) + } + composeTestRule.waitForIdle() + + assertCompactLayoutTopology(observedMode, observedLayout, CameraLayoutMode.COMPACT_SLIM) + } + + @Config(qualifiers = "sw800dp-w480dp-h800dp-port-mdpi") + @Test + fun tabletNarrowSplitScreen_sw800dp_w480dp_h800dp_resolvesToCompactAndClearsStatusBar() { + var observedMode: CameraLayoutMode? = null + var observedLayout: String? = null + lateinit var hostView: View + + composeTestRule.setContent { + hostView = LocalView.current + OrchestratedLayoutUnderTest( + onModeObserved = { observedMode = it }, + onActiveLayoutObserved = { observedLayout = it } + ) + } + composeTestRule.waitForIdle() + + assertCompactLayoutTopology(observedMode, observedLayout, CameraLayoutMode.COMPACT) + + 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_resolvesToFoldableLayout() { + var observedMode: CameraLayoutMode? = null + var observedLayout: String? = null + + composeTestRule.setContent { + OrchestratedLayoutUnderTest( + onModeObserved = { observedMode = it }, + onActiveLayoutObserved = { observedLayout = it } + ) + } + composeTestRule.waitForIdle() + + assertSideRailTopology( + observedMode, + observedLayout, + CameraLayoutMode.FOLDABLE, + "FoldableLayout" + ) + } + + @Config(qualifiers = "sw673dp-w883dp-h852dp-land-mdpi") + @Test + fun unfoldedFoldableInnerDisplay_landscape_resolvesToFoldableLayout() { + var observedMode: CameraLayoutMode? = null + var observedLayout: String? = null + + composeTestRule.setContent { + OrchestratedLayoutUnderTest( + onModeObserved = { observedMode = it }, + onActiveLayoutObserved = { observedLayout = it } + ) + } + composeTestRule.waitForIdle() + + assertSideRailTopology( + observedMode, + observedLayout, + CameraLayoutMode.FOLDABLE, + "FoldableLayout" + ) + } + + @Config(qualifiers = "sw800dp-w1280dp-h800dp-land-mdpi") + @Test + 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 = { readyUiState }, + surfaceRequest = null, + state = state, + quickSettingsController = quickSettingsController, + overlay = { + observedMode = LocalCameraLayoutMode.current + observedLayout = LocalActiveCaptureLayout.current + } + ) + } + } + } + composeTestRule.waitForIdle() + + assertSideRailTopology( + observedMode, + observedLayout, + 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 real ToggleQuickSettingsButton opens QuickSettingsContent inside the card shell. + composeTestRule.onNodeWithTag(QUICK_SETTINGS_DROP_DOWN).performClick() + composeTestRule.waitForIdle() + + assertThat(state.isQuickSettingsOpen).isTrue() + composeTestRule.onNodeWithTag(QUICK_SETTINGS_OVERLAY_CARD_PLACEHOLDER_TAG) + .assertIsDisplayed() + assertThat( + composeTestRule.onAllNodesWithTag(QUICK_SETTINGS_BOTTOM_SHEET).fetchSemanticsNodes() + ).hasSize(1) + } + + @Config(qualifiers = "sw800dp-w800dp-h1280dp-port-mdpi") + @Test + fun tabletPortrait_resolvesToTabletPortraitLayout() { + var observedMode: CameraLayoutMode? = null + var observedLayout: String? = null + + composeTestRule.setContent { + OrchestratedLayoutUnderTest( + onModeObserved = { observedMode = it }, + onActiveLayoutObserved = { observedLayout = it } + ) + } + composeTestRule.waitForIdle() + + assertSideRailTopology( + observedMode, + observedLayout, + CameraLayoutMode.TABLET_PORTRAIT, + "TabletPortraitLayout" + ) + } + + @Config(qualifiers = "sw600dp-w800dp-h400dp-land-mdpi") + @Test + fun horizontalSplitScreen_sw600dp_w800dp_h400dp_resolvesToCompact() { + var observedMode: CameraLayoutMode? = null + var observedLayout: String? = null + + composeTestRule.setContent { + OrchestratedLayoutUnderTest( + onModeObserved = { observedMode = it }, + onActiveLayoutObserved = { observedLayout = it } + ) + } + composeTestRule.waitForIdle() + + assertCompactLayoutTopology(observedMode, observedLayout, CameraLayoutMode.COMPACT) + } + + @Config(qualifiers = "sw360dp-w360dp-h360dp-mdpi") + @Test + fun clamshellCoverOrPhoneSplit_sw360dp_w360dp_h360dp_resolvesToCompactShort() { + val state = CaptureScaffoldState(isInitiallyExpanded = false) + var observedMode: CameraLayoutMode? = null + var observedLayout: String? = null + + composeTestRule.setContent { + OrchestratedLayoutUnderTest( + state = state, + onModeObserved = { observedMode = it }, + onActiveLayoutObserved = { observedLayout = it } + ) + } + composeTestRule.waitForIdle() + + assertSideRailTopology( + observedMode, + observedLayout, + CameraLayoutMode.COMPACT_SHORT, + "CompactShortLayout" + ) + composeTestRule.onNodeWithTag(QUICK_SETTINGS_BOTTOM_SHEET).assertDoesNotExist() + + composeTestRule.runOnIdle { state.showQuickSettings() } + composeTestRule.waitForIdle() + composeTestRule.onNodeWithTag(QUICK_SETTINGS_OVERLAY_CARD_PLACEHOLDER_TAG) + .assertIsDisplayed() + 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 + var observedLayout: String? = null + + composeTestRule.setContent { + OrchestratedLayoutUnderTest( + onModeObserved = { observedMode = it }, + onActiveLayoutObserved = { observedLayout = it } + ) + } + composeTestRule.waitForIdle() + + assertSideRailTopology( + observedMode, + observedLayout, + CameraLayoutMode.COMPACT_SHORT_SLIM, + "CompactShortLayout" + ) + } + + @Config(qualifiers = "sw800dp-w1280dp-h1280dp-mdpi") + @Test + fun dynamicResize_acrossAllSevenModes_preservesViewfinderWithMovableContentOf() { + val windowSizeState = mutableStateOf(DpSize(400.dp, 800.dp)) + var composeCount = 0 + var disposeCount = 0 + var observedMode: CameraLayoutMode? = null + var observedLayout: String? = null + + composeTestRule.setContent { + Box(modifier = Modifier.size(windowSizeState.value)) { + OrchestratedLayoutUnderTest( + slots = PlaceholderCaptureSlots.sideRail(includeElapsedTime = true), + onModeObserved = { observedMode = it }, + onActiveLayoutObserved = { observedLayout = it }, + viewfinderContent = { vfModifier -> + DisposableEffect(Unit) { + composeCount++ + onDispose { disposeCount++ } + } + Box( + modifier = vfModifier + .fillMaxSize() + .testTag(PREVIEW_DISPLAY) + ) + } + ) + } + } + composeTestRule.waitForIdle() + + // 1. 400x800dp -> COMPACT + assertCompactLayoutTopology(observedMode, observedLayout, CameraLayoutMode.COMPACT) + assertThat(composeCount).isEqualTo(1) + assertThat(disposeCount).isEqualTo(0) + + // 2. 604x800dp -> inside [593dp, 607dp] deadband -> stays COMPACT + windowSizeState.value = DpSize(604.dp, 800.dp) + composeTestRule.waitForIdle() + assertCompactLayoutTopology(observedMode, observedLayout, CameraLayoutMode.COMPACT) + assertThat(composeCount).isEqualTo(1) + assertThat(disposeCount).isEqualTo(0) + + // 3. 852x883dp -> crosses width deadband -> FOLDABLE + windowSizeState.value = DpSize(852.dp, 883.dp) + composeTestRule.waitForIdle() + assertSideRailTopology( + observedMode, + observedLayout, + CameraLayoutMode.FOLDABLE, + "FoldableLayout" + ) + assertThat(composeCount).isEqualTo(1) + assertThat(disposeCount).isEqualTo(0) + + // 4. 800x1280dp -> crosses foldable span -> TABLET_PORTRAIT + windowSizeState.value = DpSize(800.dp, 1280.dp) + composeTestRule.waitForIdle() + assertSideRailTopology( + observedMode, + observedLayout, + CameraLayoutMode.TABLET_PORTRAIT, + "TabletPortraitLayout" + ) + assertThat(composeCount).isEqualTo(1) + assertThat(disposeCount).isEqualTo(0) + + // 5. 1280x800dp -> rotates to TABLET_LANDSCAPE + windowSizeState.value = DpSize(1280.dp, 800.dp) + composeTestRule.waitForIdle() + assertSideRailTopology( + observedMode, + observedLayout, + CameraLayoutMode.TABLET_LANDSCAPE, + "TabletLandscapeLayout" + ) + assertThat(composeCount).isEqualTo(1) + assertThat(disposeCount).isEqualTo(0) + + // 6. 800x400dp -> crosses height deadband -> COMPACT (horizontal) + windowSizeState.value = DpSize(800.dp, 400.dp) + composeTestRule.waitForIdle() + assertCompactLayoutTopology(observedMode, observedLayout, CameraLayoutMode.COMPACT) + assertThat(composeCount).isEqualTo(1) + assertThat(disposeCount).isEqualTo(0) + + // 7. 400x400dp -> COMPACT_SHORT + windowSizeState.value = DpSize(400.dp, 400.dp) + composeTestRule.waitForIdle() + assertSideRailTopology( + observedMode, + observedLayout, + CameraLayoutMode.COMPACT_SHORT, + "CompactShortLayout" + ) + assertThat(composeCount).isEqualTo(1) + assertThat(disposeCount).isEqualTo(0) + + // 8. 320x340dp -> COMPACT_SHORT_SLIM + windowSizeState.value = DpSize(320.dp, 340.dp) + composeTestRule.waitForIdle() + assertSideRailTopology( + observedMode, + observedLayout, + CameraLayoutMode.COMPACT_SHORT_SLIM, + "CompactShortLayout" + ) + assertThat(composeCount).isEqualTo(1) + assertThat(disposeCount).isEqualTo(0) + + // 9. 320x640dp -> COMPACT_SLIM + windowSizeState.value = DpSize(320.dp, 640.dp) + composeTestRule.waitForIdle() + assertCompactLayoutTopology(observedMode, observedLayout, CameraLayoutMode.COMPACT_SLIM) + assertThat(composeCount).isEqualTo(1) + assertThat(disposeCount).isEqualTo(0) + + // 10. 400x800dp -> returns to COMPACT without ever disposing viewfinder + windowSizeState.value = DpSize(400.dp, 800.dp) + composeTestRule.waitForIdle() + assertCompactLayoutTopology(observedMode, observedLayout, CameraLayoutMode.COMPACT) + 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..969440aae5 --- /dev/null +++ b/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/layout/CameraLayoutModeTest.kt @@ -0,0 +1,404 @@ +/* + * 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 7-mode classification (`COMPACT`, `COMPACT_SLIM`, `COMPACT_SHORT`, `COMPACT_SHORT_SLIM`, + * `FOLDABLE`, `TABLET_PORTRAIT`, `TABLET_LANDSCAPE`), posture rotation pairs, full-screen compact + * phone classification, and the `14.dp` (`±7.dp`) hysteresis deadbands across all boundaries. + */ +@RunWith(JUnit4::class) +class CameraLayoutModeTest { + + @Test + fun classifyCameraLayoutMode_initialClassification_resolvesAllSevenModes() { + // 1. COMPACT: portrait & horizontal windows (maxDim >= 480dp, minDim >= 360dp, not 600x480) + assertThat( + classifyCameraLayoutMode(widthDp = 412.dp, heightDp = 915.dp) + ).isEqualTo(CameraLayoutMode.COMPACT) + assertThat( + classifyCameraLayoutMode(widthDp = 599.dp, heightDp = 480.dp) + ).isEqualTo(CameraLayoutMode.COMPACT) + assertThat( + classifyCameraLayoutMode(widthDp = 800.dp, heightDp = 400.dp) + ).isEqualTo(CameraLayoutMode.COMPACT) + assertThat( + classifyCameraLayoutMode(widthDp = 550.dp, heightDp = 500.dp) + ).isEqualTo(CameraLayoutMode.COMPACT) + + // 2. 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) + + // 3. COMPACT_SHORT: width < 480dp, height < 480dp, minDim >= 360dp + assertThat( + classifyCameraLayoutMode(widthDp = 412.dp, heightDp = 440.dp) + ).isEqualTo(CameraLayoutMode.COMPACT_SHORT) + assertThat( + classifyCameraLayoutMode(widthDp = 360.dp, heightDp = 360.dp) + ).isEqualTo(CameraLayoutMode.COMPACT_SHORT) + + // 4. 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) + + // 5. FOLDABLE: width >= 600dp, height >= 480dp, maxDim < 960dp, maxDim < minDim * 1.45f + assertThat( + classifyCameraLayoutMode(widthDp = 852.dp, heightDp = 883.dp) + ).isEqualTo(CameraLayoutMode.FOLDABLE) + assertThat( + classifyCameraLayoutMode(widthDp = 673.dp, heightDp = 841.dp) + ).isEqualTo(CameraLayoutMode.FOLDABLE) + assertThat( + classifyCameraLayoutMode(widthDp = 841.dp, heightDp = 673.dp) + ).isEqualTo(CameraLayoutMode.FOLDABLE) + + // 6. TABLET_PORTRAIT: width >= 600dp, height >= 480dp, outside FOLDABLE, height > width + assertThat( + classifyCameraLayoutMode(widthDp = 800.dp, heightDp = 1280.dp) + ).isEqualTo(CameraLayoutMode.TABLET_PORTRAIT) + assertThat( + classifyCameraLayoutMode(widthDp = 600.dp, heightDp = 960.dp) + ).isEqualTo(CameraLayoutMode.TABLET_PORTRAIT) + assertThat( + classifyCameraLayoutMode(widthDp = 600.dp, heightDp = 900.dp) + ).isEqualTo(CameraLayoutMode.TABLET_PORTRAIT) + + // 7. TABLET_LANDSCAPE: width >= 600dp, height >= 480dp, outside FOLDABLE, width >= height + assertThat( + classifyCameraLayoutMode(widthDp = 1280.dp, heightDp = 800.dp) + ).isEqualTo(CameraLayoutMode.TABLET_LANDSCAPE) + assertThat( + classifyCameraLayoutMode(widthDp = 960.dp, heightDp = 600.dp) + ).isEqualTo(CameraLayoutMode.TABLET_LANDSCAPE) + assertThat( + classifyCameraLayoutMode(widthDp = 1024.dp, heightDp = 768.dp) + ).isEqualTo(CameraLayoutMode.TABLET_LANDSCAPE) + } + + @Test + fun classifyCameraLayoutMode_rotationPairs_preservePostureFamilies() { + // 60/40 compact split rotated: both stay COMPACT + 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) + assertThat(landscapeSplit).isEqualTo(CameraLayoutMode.COMPACT) + + // 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) + + // Unfolded foldable rotated: stays FOLDABLE + val foldablePortrait = classifyCameraLayoutMode(widthDp = 673.dp, heightDp = 841.dp) + val foldableLandscape = classifyCameraLayoutMode( + widthDp = 841.dp, + heightDp = 673.dp, + previousMode = foldablePortrait + ) + assertThat(foldablePortrait).isEqualTo(CameraLayoutMode.FOLDABLE) + assertThat(foldableLandscape).isEqualTo(CameraLayoutMode.FOLDABLE) + + // Tablet rotated: swaps TABLET_PORTRAIT <-> TABLET_LANDSCAPE + val tabletPortrait = classifyCameraLayoutMode(widthDp = 800.dp, heightDp = 1280.dp) + val tabletLandscape = classifyCameraLayoutMode( + widthDp = 1280.dp, + heightDp = 800.dp, + previousMode = tabletPortrait + ) + assertThat(tabletPortrait).isEqualTo(CameraLayoutMode.TABLET_PORTRAIT) + assertThat(tabletLandscape).isEqualTo(CameraLayoutMode.TABLET_LANDSCAPE) + } + + @Test + fun classifyCameraLayoutMode_fullScreenCompactPhone_resolvesCompactOrCompactSlim() { + assertThat( + classifyCameraLayoutMode( + widthDp = 360.dp, + heightDp = 740.dp, + isFullScreenCompactPhone = true + ) + ).isEqualTo(CameraLayoutMode.COMPACT) + + assertThat( + classifyCameraLayoutMode( + widthDp = 740.dp, + heightDp = 360.dp, + isFullScreenCompactPhone = true + ) + ).isEqualTo(CameraLayoutMode.COMPACT) + + assertThat( + classifyCameraLayoutMode( + widthDp = 915.dp, + heightDp = 500.dp, + isFullScreenCompactPhone = true + ) + ).isEqualTo(CameraLayoutMode.COMPACT) + + 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 inside [593dp, 606dp], enters FOLDABLE at >= 607dp. + assertThat(stepWidth(580.dp)).isEqualTo(CameraLayoutMode.COMPACT) + assertThat(stepWidth(600.dp)).isEqualTo(CameraLayoutMode.COMPACT) + assertThat(stepWidth(606.dp)).isEqualTo(CameraLayoutMode.COMPACT) + assertThat(stepWidth(608.dp)).isEqualTo(CameraLayoutMode.FOLDABLE) + + // Shrinking from foldable: stays FOLDABLE down to 593dp, exits below 593dp. + assertThat(stepWidth(600.dp)).isEqualTo(CameraLayoutMode.FOLDABLE) + assertThat(stepWidth(593.dp)).isEqualTo(CameraLayoutMode.FOLDABLE) + assertThat(stepWidth(592.dp)).isEqualTo(CameraLayoutMode.COMPACT) + } + + @Test + fun classifyCameraLayoutMode_widthHysteresisSweep_shortHeight_compactShortToCompact() { + var mode: CameraLayoutMode? = null + fun stepWidth(width: Dp): CameraLayoutMode { + mode = classifyCameraLayoutMode( + widthDp = width, + heightDp = 400.dp, + isFullScreenCompactPhone = false, + previousMode = mode + ) + return checkNotNull(mode) + } + + 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) + + assertThat(stepWidth(480.dp)).isEqualTo(CameraLayoutMode.COMPACT) + assertThat(stepWidth(473.dp)).isEqualTo(CameraLayoutMode.COMPACT) + 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) + } + + // 800x500dp has maxDim (800) >= minDim * 1.45 (725), so it resolves to TABLET_LANDSCAPE. + assertThat(stepHeight(500.dp)).isEqualTo(CameraLayoutMode.TABLET_LANDSCAPE) + assertThat(stepHeight(480.dp)).isEqualTo(CameraLayoutMode.TABLET_LANDSCAPE) + assertThat(stepHeight(473.dp)).isEqualTo(CameraLayoutMode.TABLET_LANDSCAPE) + assertThat(stepHeight(472.dp)).isEqualTo(CameraLayoutMode.COMPACT) + + assertThat(stepHeight(480.dp)).isEqualTo(CameraLayoutMode.COMPACT) + assertThat(stepHeight(486.dp)).isEqualTo(CameraLayoutMode.COMPACT) + assertThat(stepHeight(488.dp)).isEqualTo(CameraLayoutMode.TABLET_LANDSCAPE) + } + + @Test + fun classifyCameraLayoutMode_foldableSpanAndAspectHysteresis_preventsTabletBoundaryThrash() { + var mode: CameraLayoutMode? = null + fun stepSpan(width: Dp): CameraLayoutMode { + mode = classifyCameraLayoutMode( + widthDp = width, + heightDp = 800.dp, + isFullScreenCompactPhone = false, + previousMode = mode + ) + return checkNotNull(mode) + } + + // Expanding across 960dp span: stays FOLDABLE up to 966dp, enters TABLET_LANDSCAPE at 967dp + assertThat(stepSpan(880.dp)).isEqualTo(CameraLayoutMode.FOLDABLE) + assertThat(stepSpan(960.dp)).isEqualTo(CameraLayoutMode.FOLDABLE) + assertThat(stepSpan(966.dp)).isEqualTo(CameraLayoutMode.FOLDABLE) + assertThat(stepSpan(967.dp)).isEqualTo(CameraLayoutMode.TABLET_LANDSCAPE) + + // Shrinking from TABLET_LANDSCAPE: stays TABLET_LANDSCAPE down to 953dp, enters at 952dp + assertThat(stepSpan(960.dp)).isEqualTo(CameraLayoutMode.TABLET_LANDSCAPE) + assertThat(stepSpan(953.dp)).isEqualTo(CameraLayoutMode.TABLET_LANDSCAPE) + assertThat(stepSpan(952.dp)).isEqualTo(CameraLayoutMode.FOLDABLE) + + // Aspect ratio boundary at width = 620dp (620 * 1.45 = 899dp, deadband [892dp, 906dp]) + var aspectMode: CameraLayoutMode? = null + fun stepTallHeight(height: Dp): CameraLayoutMode { + aspectMode = classifyCameraLayoutMode( + widthDp = 620.dp, + heightDp = height, + isFullScreenCompactPhone = false, + previousMode = aspectMode + ) + return checkNotNull(aspectMode) + } + + assertThat(stepTallHeight(850.dp)).isEqualTo(CameraLayoutMode.FOLDABLE) + assertThat(stepTallHeight(905.dp)).isEqualTo(CameraLayoutMode.FOLDABLE) + assertThat(stepTallHeight(906.dp)).isEqualTo(CameraLayoutMode.TABLET_PORTRAIT) + assertThat(stepTallHeight(892.dp)).isEqualTo(CameraLayoutMode.TABLET_PORTRAIT) + assertThat(stepTallHeight(891.dp)).isEqualTo(CameraLayoutMode.FOLDABLE) + } + + @Test + fun classifyCameraLayoutMode_tabletSquareDiagonalHysteresis_stabilizesPortraitVsLandscape() { + var mode: CameraLayoutMode? = null + fun stepSize(width: Dp, height: Dp): CameraLayoutMode { + mode = classifyCameraLayoutMode( + widthDp = width, + heightDp = height, + isFullScreenCompactPhone = false, + previousMode = mode + ) + return checkNotNull(mode) + } + + assertThat(stepSize(980.dp, 1020.dp)).isEqualTo(CameraLayoutMode.TABLET_PORTRAIT) + assertThat(stepSize(1020.dp, 1020.dp)).isEqualTo(CameraLayoutMode.TABLET_PORTRAIT) + assertThat(stepSize(1026.dp, 1020.dp)).isEqualTo(CameraLayoutMode.TABLET_PORTRAIT) + assertThat(stepSize(1027.dp, 1020.dp)).isEqualTo(CameraLayoutMode.TABLET_LANDSCAPE) + + assertThat(stepSize(1020.dp, 1020.dp)).isEqualTo(CameraLayoutMode.TABLET_LANDSCAPE) + assertThat(stepSize(1020.dp, 1026.dp)).isEqualTo(CameraLayoutMode.TABLET_LANDSCAPE) + assertThat(stepSize(1020.dp, 1027.dp)).isEqualTo(CameraLayoutMode.TABLET_PORTRAIT) + } + + @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) + assertThat(stepWidth(360.dp)).isEqualTo(CameraLayoutMode.COMPACT) + assertThat(stepWidth(353.dp)).isEqualTo(CameraLayoutMode.COMPACT) + 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) + } + + @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() + } +}