From 6ca0e1eb0cd608b64d9022738458ddc80696616a Mon Sep 17 00:00:00 2001 From: Kimberly Crevecoeur Date: Fri, 6 Nov 2026 10:35:27 -0800 Subject: [PATCH] feat(ui/capture): add StartSideRail and EndSideRail adaptive components --- .../ui/components/capture/CaptureSideRails.kt | 624 ++++++++++++++++++ .../ui/components/capture/TestTags.kt | 6 + .../capture/CaptureSideRailsTest.kt | 493 ++++++++++++++ 3 files changed, 1123 insertions(+) create mode 100644 ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureSideRails.kt create mode 100644 ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/CaptureSideRailsTest.kt diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureSideRails.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureSideRails.kt new file mode 100644 index 000000000..48abeb806 --- /dev/null +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/CaptureSideRails.kt @@ -0,0 +1,624 @@ +/* + * 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 androidx.compose.foundation.background +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.Placeable +import androidx.compose.ui.layout.layoutId +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +private object Tokens { + val MinRailWidth = 72.dp + val HorizontalPadding = 8.dp + val StandardVerticalPadding = 16.dp + val CompactVerticalPadding = 8.dp + val StandardMinSectionGap = 12.dp + val CompactMinSectionGap = 8.dp + + val StandardShutterClusterGap = 48.dp + val CompactShutterClusterGap = 20.dp + + val PreviewStandardRailHeight = 800.dp + val PreviewCompactRailHeight = 360.dp + val PreviewBackgroundColor = Color(0xFF121212) +} + +private enum class RailSectionId { + Top, + Center, + Bottom +} + +private enum class ShutterClusterSlotId { + FlipCamera, + Capture, + ImageWell, + PauseToggle +} + +/** + * Start-aligned vertical control rail for landscape, tabletop, and large-screen capture layouts. + * + * Tagged with [START_SIDE_RAIL_TAG] and provides a vertical [CaptureSlotScope] to its children. + * Stacks [CaptureSlots.indicatorColumn] at the top, [CaptureSlots.zoomControls] in the center, and + * [CaptureSlots.quickSettingsButton] at the bottom in strict top-to-bottom order across both LTR + * and RTL locales while preserving the caller's `LocalLayoutDirection` inside each child slot. + * + * @param slots [CaptureSlots] supplying the indicator, zoom, and Quick Settings controls. + * @param modifier [Modifier] applied to the rail container. + * @param isCompactHeight Whether compact vertical padding, section gaps, and slot sizing hints are + * enabled for short windows. + * @param isRecording Whether an active video recording session is in progress. + * @param onDismissQuickSettings Callback propagated to [CaptureSlotScope.dismissQuickSettings]. + */ +@Composable +fun StartSideRail( + slots: CaptureSlots, + modifier: Modifier = Modifier, + isCompactHeight: Boolean = false, + isRecording: Boolean = false, + onDismissQuickSettings: () -> Unit = {} +) { + val verticalPadding = if (isCompactHeight) { + Tokens.CompactVerticalPadding + } else { + Tokens.StandardVerticalPadding + } + val minSectionGap = if (isCompactHeight) { + Tokens.CompactMinSectionGap + } else { + Tokens.StandardMinSectionGap + } + + VerticalRailLayout( + modifier = modifier.testTag(START_SIDE_RAIL_TAG), + verticalPadding = verticalPadding, + horizontalPadding = Tokens.HorizontalPadding, + minSectionGap = minSectionGap, + topContent = { + OverlapAwareCaptureSlot( + orientation = Orientation.Vertical, + isCompact = isCompactHeight, + isRecording = isRecording, + onDismissQuickSettings = onDismissQuickSettings, + content = slots.indicatorColumn + ) + }, + centerContent = { + OverlapAwareCaptureSlot( + orientation = Orientation.Vertical, + isCompact = isCompactHeight, + isRecording = isRecording, + onDismissQuickSettings = onDismissQuickSettings, + content = slots.zoomControls + ) + }, + bottomContent = { + OverlapAwareCaptureSlot( + orientation = Orientation.Vertical, + isCompact = isCompactHeight, + isRecording = isRecording, + onDismissQuickSettings = onDismissQuickSettings, + content = slots.quickSettingsButton + ) + } + ) +} + +/** + * End-aligned vertical shutter and mode-selection rail for landscape, tabletop, and large-screen + * capture layouts. + * + * Tagged with [END_SIDE_RAIL_TAG] and provides a vertical [CaptureSlotScope] to its children. + * Places [CaptureSlots.elapsedTimeDisplay] at the top when [showElapsedTimeInRail] is `true`, + * renders either [CaptureSlots.shutterStack] or a collision-free vertical cluster of + * `flipCameraButton -> captureButton -> imageWell -> pauseToggleButton` in the center, and anchors + * [CaptureSlots.captureModeToggle] at the bottom in strict top-to-bottom order across both LTR and + * RTL locales. + * + * @param slots [CaptureSlots] supplying the shutter cluster, elapsed timer, and mode toggle. + * @param modifier [Modifier] applied to the rail container. + * @param isCompactHeight Whether compact vertical spacing and compact slot hints are active. + * @param isRecording Whether an active video recording session is in progress. + * @param showElapsedTimeInRail Whether [CaptureSlots.elapsedTimeDisplay] is hosted in the top slot + * of this rail. + * @param onDismissQuickSettings Callback propagated to [CaptureSlotScope.dismissQuickSettings]. + */ +@Composable +fun EndSideRail( + slots: CaptureSlots, + modifier: Modifier = Modifier, + isCompactHeight: Boolean = false, + isRecording: Boolean = false, + showElapsedTimeInRail: Boolean = false, + onDismissQuickSettings: () -> Unit = {} +) { + val verticalPadding = if (isCompactHeight) { + Tokens.CompactVerticalPadding + } else { + Tokens.StandardVerticalPadding + } + val minSectionGap = if (isCompactHeight) { + Tokens.CompactMinSectionGap + } else { + Tokens.StandardMinSectionGap + } + val customShutterStack = slots.shutterStack + + VerticalRailLayout( + modifier = modifier.testTag(END_SIDE_RAIL_TAG), + verticalPadding = verticalPadding, + horizontalPadding = Tokens.HorizontalPadding, + minSectionGap = minSectionGap, + topContent = { + if (showElapsedTimeInRail) { + OverlapAwareCaptureSlot( + orientation = Orientation.Vertical, + isCompact = isCompactHeight, + isRecording = isRecording, + onDismissQuickSettings = onDismissQuickSettings, + content = slots.elapsedTimeDisplay + ) + } + }, + centerContent = { + if (customShutterStack != null) { + OverlapAwareCaptureSlot( + orientation = Orientation.Vertical, + isCompact = isCompactHeight, + isRecording = isRecording, + onDismissQuickSettings = onDismissQuickSettings + ) { slotModifier -> + customShutterStack(slotModifier, isCompactHeight) + } + } else { + VerticalRailShutterCluster( + slots = slots, + isCompactHeight = isCompactHeight, + isRecording = isRecording, + onDismissQuickSettings = onDismissQuickSettings + ) + } + }, + bottomContent = { + OverlapAwareCaptureSlot( + orientation = Orientation.Vertical, + isCompact = isCompactHeight, + isRecording = isRecording, + onDismissQuickSettings = onDismissQuickSettings, + content = slots.captureModeToggle + ) + } + ) +} + +/** + * Collision-free 3-band vertical rail layout (Top, Center, Bottom). + * + * Measures Top and Bottom first, constrains Center's `maxHeight` to the remaining vertical budget + * minus [minSectionGap] buffers, and places all sections using explicit `place(x, y)` coordinates + * so RTL layout direction never alters vertical ordering while child composables retain the + * ambient `LocalLayoutDirection`. + */ +@Composable +private fun VerticalRailLayout( + modifier: Modifier = Modifier, + verticalPadding: Dp, + horizontalPadding: Dp, + minSectionGap: Dp, + topContent: @Composable () -> Unit, + centerContent: @Composable () -> Unit, + bottomContent: @Composable () -> Unit +) { + Layout( + modifier = modifier, + content = { + Box(modifier = Modifier.layoutId(RailSectionId.Top)) { + topContent() + } + Box(modifier = Modifier.layoutId(RailSectionId.Center)) { + centerContent() + } + Box(modifier = Modifier.layoutId(RailSectionId.Bottom)) { + bottomContent() + } + } + ) { measurables, constraints -> + val verticalPadPx = verticalPadding.roundToPx() + val horizontalPadPx = horizontalPadding.roundToPx() + val gapPx = minSectionGap.roundToPx() + + val innerMaxWidth = if (constraints.hasBoundedWidth) { + (constraints.maxWidth - horizontalPadPx * 2).coerceAtLeast(0) + } else { + Constraints.Infinity + } + + val topMeasurable = measurables.firstOrNull { it.layoutId == RailSectionId.Top } + val centerMeasurable = measurables.firstOrNull { it.layoutId == RailSectionId.Center } + val bottomMeasurable = measurables.firstOrNull { it.layoutId == RailSectionId.Bottom } + + val topPlaceable: Placeable? + val centerPlaceable: Placeable? + val bottomPlaceable: Placeable? + val railHeight: Int + + if (constraints.hasBoundedHeight) { + railHeight = constraints.maxHeight + val innerHeight = (railHeight - verticalPadPx * 2).coerceAtLeast(0) + + topPlaceable = topMeasurable?.measure( + Constraints( + minWidth = 0, + maxWidth = innerMaxWidth, + minHeight = 0, + maxHeight = innerHeight + ) + )?.takeIf { it.height > 0 } + + val topReserved = topPlaceable?.let { it.height + gapPx } ?: 0 + val remainingForBottom = (innerHeight - topReserved).coerceAtLeast(0) + + bottomPlaceable = bottomMeasurable?.measure( + Constraints( + minWidth = 0, + maxWidth = innerMaxWidth, + minHeight = 0, + maxHeight = remainingForBottom + ) + )?.takeIf { it.height > 0 } + + val bottomReserved = bottomPlaceable?.let { it.height + gapPx } ?: 0 + val availableCenterHeight = (remainingForBottom - bottomReserved).coerceAtLeast(0) + + centerPlaceable = centerMeasurable?.measure( + Constraints( + minWidth = 0, + maxWidth = innerMaxWidth, + minHeight = 0, + maxHeight = availableCenterHeight + ) + )?.takeIf { it.height > 0 } + } else { + val looseConstraints = Constraints( + minWidth = 0, + maxWidth = innerMaxWidth, + minHeight = 0, + maxHeight = Constraints.Infinity + ) + topPlaceable = topMeasurable?.measure(looseConstraints)?.takeIf { it.height > 0 } + bottomPlaceable = bottomMeasurable?.measure(looseConstraints)?.takeIf { it.height > 0 } + centerPlaceable = centerMeasurable?.measure(looseConstraints)?.takeIf { it.height > 0 } + + val activeSections = listOfNotNull(topPlaceable, centerPlaceable, bottomPlaceable) + val totalGapPx = (activeSections.size - 1).coerceAtLeast(0) * gapPx + val intrinsicHeight = activeSections.sumOf { it.height } + + totalGapPx + + verticalPadPx * 2 + railHeight = intrinsicHeight.coerceAtLeast(constraints.minHeight) + } + + val maxContentWidth = maxOf( + topPlaceable?.width ?: 0, + centerPlaceable?.width ?: 0, + bottomPlaceable?.width ?: 0 + ) + val railWidth = maxOf( + Tokens.MinRailWidth.roundToPx(), + maxContentWidth + horizontalPadPx * 2 + ).coerceIn(constraints.minWidth, constraints.maxWidth) + + val topY = verticalPadPx + val minBottomY = topPlaceable?.let { topY + it.height + gapPx } ?: verticalPadPx + val bottomY = bottomPlaceable?.let { bottom -> + (railHeight - verticalPadPx - bottom.height).coerceAtLeast(minBottomY) + } ?: (railHeight - verticalPadPx) + + val minCenterY = topPlaceable?.let { topY + it.height + gapPx } ?: verticalPadPx + val maxCenterY = centerPlaceable?.let { center -> + if (bottomPlaceable != null) { + bottomY - gapPx - center.height + } else { + railHeight - verticalPadPx - center.height + } + } ?: minCenterY + + layout(railWidth, railHeight) { + topPlaceable?.let { placeable -> + placeable.place( + x = (railWidth - placeable.width) / 2, + y = topY + ) + } + + centerPlaceable?.let { placeable -> + val idealCenterY = (railHeight - placeable.height) / 2 + val centerY = if (minCenterY <= maxCenterY) { + idealCenterY.coerceIn(minCenterY, maxCenterY) + } else { + minCenterY + } + placeable.place( + x = (railWidth - placeable.width) / 2, + y = centerY + ) + } + + bottomPlaceable?.let { placeable -> + placeable.place( + x = (railWidth - placeable.width) / 2, + y = bottomY + ) + } + } + } +} + +/** + * Default vertical shutter cluster for [EndSideRail], stacking + * `flipCameraButton -> captureButton -> imageWell -> pauseToggleButton` strictly top-to-bottom. + * + * Collapses `0x0` omitted slots without adding extra gaps and compresses the inter-control gap + * proportionally when constrained by short window heights. + */ +@Composable +private fun VerticalRailShutterCluster( + slots: CaptureSlots, + isCompactHeight: Boolean, + isRecording: Boolean, + onDismissQuickSettings: () -> Unit, + modifier: Modifier = Modifier +) { + Layout( + modifier = modifier, + content = { + OverlapAwareCaptureSlot( + modifier = Modifier.layoutId(ShutterClusterSlotId.FlipCamera), + orientation = Orientation.Vertical, + isCompact = isCompactHeight, + isRecording = isRecording, + onDismissQuickSettings = onDismissQuickSettings, + content = slots.flipCameraButton + ) + OverlapAwareCaptureSlot( + modifier = Modifier.layoutId(ShutterClusterSlotId.Capture), + orientation = Orientation.Vertical, + isCompact = isCompactHeight, + isRecording = isRecording, + onDismissQuickSettings = onDismissQuickSettings, + content = slots.captureButton + ) + OverlapAwareCaptureSlot( + modifier = Modifier.layoutId(ShutterClusterSlotId.ImageWell), + orientation = Orientation.Vertical, + isCompact = isCompactHeight, + isRecording = isRecording, + onDismissQuickSettings = onDismissQuickSettings, + content = slots.imageWell + ) + OverlapAwareCaptureSlot( + modifier = Modifier.layoutId(ShutterClusterSlotId.PauseToggle), + orientation = Orientation.Vertical, + isCompact = isCompactHeight, + isRecording = isRecording, + onDismissQuickSettings = onDismissQuickSettings, + content = slots.pauseToggleButton + ) + } + ) { measurables, constraints -> + val targetGapPx = if (isCompactHeight) { + Tokens.CompactShutterClusterGap + } else { + Tokens.StandardShutterClusterGap + }.roundToPx() + + val activePlaceables = if (constraints.hasBoundedHeight) { + var remainingHeight = constraints.maxHeight + fun measureSlot(id: ShutterClusterSlotId): Placeable? { + val measurable = measurables.firstOrNull { it.layoutId == id } ?: return null + val placeable = measurable.measure( + Constraints( + minWidth = 0, + maxWidth = constraints.maxWidth, + minHeight = 0, + maxHeight = remainingHeight + ) + ) + if (placeable.height > 0) { + remainingHeight = (remainingHeight - placeable.height).coerceAtLeast(0) + return placeable + } + return null + } + + // Measure primary shutter first so it retains priority in extreme micro-heights, then + // assemble in strict Flip -> Capture -> ImageWell -> Pause visual order. + val capturePlaceable = measureSlot(ShutterClusterSlotId.Capture) + val flipPlaceable = measureSlot(ShutterClusterSlotId.FlipCamera) + val imageWellPlaceable = measureSlot(ShutterClusterSlotId.ImageWell) + val pausePlaceable = measureSlot(ShutterClusterSlotId.PauseToggle) + listOfNotNull( + flipPlaceable, + capturePlaceable, + imageWellPlaceable, + pausePlaceable + ) + } else { + val looseConstraints = Constraints( + minWidth = 0, + maxWidth = constraints.maxWidth, + minHeight = 0, + maxHeight = Constraints.Infinity + ) + ShutterClusterSlotId.entries.mapNotNull { id -> + measurables.firstOrNull { it.layoutId == id } + ?.measure(looseConstraints) + ?.takeIf { it.height > 0 } + } + } + + val gapCount = (activePlaceables.size - 1).coerceAtLeast(0) + val totalChildrenHeight = activePlaceables.sumOf { it.height } + val targetTotalGapPx = targetGapPx * gapCount + + val actualTotalGapPx = if (constraints.hasBoundedHeight && gapCount > 0) { + val maxAllowedGapPx = (constraints.maxHeight - totalChildrenHeight).coerceAtLeast(0) + minOf(targetTotalGapPx, maxAllowedGapPx) + } else { + targetTotalGapPx + } + + val baseGapPx = if (gapCount > 0) actualTotalGapPx / gapCount else 0 + val extraGapPixels = if (gapCount > 0) actualTotalGapPx % gapCount else 0 + + val clusterWidth = (activePlaceables.maxOfOrNull { it.width } ?: 0) + .coerceIn(constraints.minWidth, constraints.maxWidth) + val clusterHeight = (totalChildrenHeight + actualTotalGapPx) + .coerceIn(constraints.minHeight, constraints.maxHeight) + + layout(clusterWidth, clusterHeight) { + var currentY = (clusterHeight - (totalChildrenHeight + actualTotalGapPx)) + .coerceAtLeast(0) / 2 + activePlaceables.forEachIndexed { index, placeable -> + placeable.place( + x = (clusterWidth - placeable.width) / 2, + y = currentY + ) + currentY += placeable.height + if (index < gapCount) { + currentY += baseGapPx + if (index < extraGapPixels) 1 else 0 + } + } + } + } +} + +@Preview(name = "StartSideRail - Standard (800dp) LTR", showBackground = true) +@Composable +private fun StartSideRailStandardLtrPreview() { + Box(modifier = Modifier.background(Tokens.PreviewBackgroundColor)) { + StartSideRail( + slots = PlaceholderCaptureSlots.sideRail(isCompactHeight = false), + modifier = Modifier.height(Tokens.PreviewStandardRailHeight) + ) + } +} + +@Preview(name = "StartSideRail - Standard (800dp) RTL", locale = "ar", showBackground = true) +@Composable +private fun StartSideRailStandardRtlPreview() { + Box(modifier = Modifier.background(Tokens.PreviewBackgroundColor)) { + StartSideRail( + slots = PlaceholderCaptureSlots.sideRail(isCompactHeight = false), + modifier = Modifier.height(Tokens.PreviewStandardRailHeight) + ) + } +} + +@Preview(name = "StartSideRail - Compact (360dp) LTR", showBackground = true) +@Composable +private fun StartSideRailCompactLtrPreview() { + Box(modifier = Modifier.background(Tokens.PreviewBackgroundColor)) { + StartSideRail( + slots = PlaceholderCaptureSlots.sideRail(isCompactHeight = true), + modifier = Modifier.height(Tokens.PreviewCompactRailHeight), + isCompactHeight = true + ) + } +} + +@Preview(name = "StartSideRail - Compact (360dp) RTL", locale = "ar", showBackground = true) +@Composable +private fun StartSideRailCompactRtlPreview() { + Box(modifier = Modifier.background(Tokens.PreviewBackgroundColor)) { + StartSideRail( + slots = PlaceholderCaptureSlots.sideRail(isCompactHeight = true), + modifier = Modifier.height(Tokens.PreviewCompactRailHeight), + isCompactHeight = true + ) + } +} + +@Preview(name = "EndSideRail - Standard (800dp) LTR", showBackground = true) +@Composable +private fun EndSideRailStandardLtrPreview() { + Box(modifier = Modifier.background(Tokens.PreviewBackgroundColor)) { + EndSideRail( + slots = PlaceholderCaptureSlots.sideRail(isCompactHeight = false), + modifier = Modifier.height(Tokens.PreviewStandardRailHeight) + ) + } +} + +@Preview(name = "EndSideRail - Standard (800dp) RTL", locale = "ar", showBackground = true) +@Composable +private fun EndSideRailStandardRtlPreview() { + Box(modifier = Modifier.background(Tokens.PreviewBackgroundColor)) { + EndSideRail( + slots = PlaceholderCaptureSlots.sideRail(isCompactHeight = false), + modifier = Modifier.height(Tokens.PreviewStandardRailHeight) + ) + } +} + +@Preview(name = "EndSideRail - Compact (360dp) Recording LTR", showBackground = true) +@Composable +private fun EndSideRailCompactLtrPreview() { + Box(modifier = Modifier.background(Tokens.PreviewBackgroundColor)) { + EndSideRail( + slots = PlaceholderCaptureSlots.sideRail( + isCompactHeight = true, + includeImageWell = true, + includePauseSlot = true, + includeElapsedTime = true + ), + modifier = Modifier.height(Tokens.PreviewCompactRailHeight), + isCompactHeight = true, + isRecording = true, + showElapsedTimeInRail = true + ) + } +} + +@Preview(name = "EndSideRail - Compact (360dp) Recording RTL", locale = "ar", showBackground = true) +@Composable +private fun EndSideRailCompactRtlPreview() { + Box(modifier = Modifier.background(Tokens.PreviewBackgroundColor)) { + EndSideRail( + slots = PlaceholderCaptureSlots.sideRail( + isCompactHeight = true, + includeImageWell = true, + includePauseSlot = true, + includeElapsedTime = true + ), + modifier = Modifier.height(Tokens.PreviewCompactRailHeight), + isCompactHeight = true, + isRecording = true, + showElapsedTimeInRail = true + ) + } +} diff --git a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/TestTags.kt b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/TestTags.kt index 10a5fd910..d6cad9c03 100644 --- a/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/TestTags.kt +++ b/ui/components/capture/src/main/java/com/google/jetpackcamera/ui/components/capture/TestTags.kt @@ -98,5 +98,11 @@ const val CAMERA_ERROR_DIALOG_TITLE_TAG = "text_camera_error_title" const val CAMERA_ERROR_DIALOG_BODY_TAG = "text_camera_error_body" const val CAMERA_ERROR_DIALOG_CONFIRM_BUTTON_TAG = "btn_camera_error_confirm" +/** Test tag for the start-aligned adaptive side rail on the capture screen. */ +const val START_SIDE_RAIL_TAG = "rail_capture_start" + +/** Test tag for the end-aligned adaptive side rail on the capture screen. */ +const val END_SIDE_RAIL_TAG = "rail_capture_end" + /** Test tag for the compact controls container on the capture screen. */ const val COMPACT_CONTROLS_TAG = "container_capture_controls_compact" diff --git a/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/CaptureSideRailsTest.kt b/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/CaptureSideRailsTest.kt new file mode 100644 index 000000000..e0029c314 --- /dev/null +++ b/ui/components/capture/src/test/java/com/google/jetpackcamera/ui/components/capture/CaptureSideRailsTest.kt @@ -0,0 +1,493 @@ +/* + * 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 androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.getBoundsInRoot +import androidx.compose.ui.test.getUnclippedBoundsInRoot +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.unit.DpRect +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.height +import androidx.compose.ui.unit.width +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Unit tests for [StartSideRail], [EndSideRail], [CaptureSlots], and [CaptureSlotScope]. + */ +@RunWith(RobolectricTestRunner::class) +@Config(qualifiers = "w800dp-h800dp") +class CaptureSideRailsTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private fun boundsOf(tag: String): DpRect = + composeTestRule.onNodeWithTag(tag).getUnclippedBoundsInRoot() + + @Test + fun startSideRail_ltrAndRtl_preservesStrictTopToBottomSlotOrder_andChildLayoutDirection() { + var layoutDirection by mutableStateOf(LayoutDirection.Ltr) + var indicatorDirection: LayoutDirection? = null + var zoomDirection: LayoutDirection? = null + var quickSettingsDirection: LayoutDirection? = null + + val baseSlots = PlaceholderCaptureSlots.sideRail() + val instrumentedSlots = baseSlots.copy( + indicatorColumn = { mod -> + indicatorDirection = LocalLayoutDirection.current + baseSlots.indicatorColumn(this, mod) + }, + zoomControls = { mod -> + zoomDirection = LocalLayoutDirection.current + baseSlots.zoomControls(this, mod) + }, + quickSettingsButton = { mod -> + quickSettingsDirection = LocalLayoutDirection.current + baseSlots.quickSettingsButton(this, mod) + } + ) + + composeTestRule.setContent { + CompositionLocalProvider(LocalLayoutDirection provides layoutDirection) { + StartSideRail( + slots = instrumentedSlots, + modifier = Modifier.height(800.dp) + ) + } + } + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithTag(START_SIDE_RAIL_TAG).assertIsDisplayed() + val ltrRail = boundsOf(START_SIDE_RAIL_TAG) + val ltrIndicator = boundsOf(VIDEO_QUALITY_TAG) + val ltrZoom = boundsOf(ZOOM_BUTTON_ROW_TAG) + val ltrQuickSettings = boundsOf(QUICK_SETTINGS_DROP_DOWN) + + assertThat(ltrIndicator.top).isAtLeast(ltrRail.top) + assertThat(ltrIndicator.bottom).isAtMost(ltrZoom.top) + assertThat(ltrZoom.bottom).isAtMost(ltrQuickSettings.top) + assertThat(ltrQuickSettings.bottom).isAtMost(ltrRail.bottom) + assertThat(indicatorDirection).isEqualTo(LayoutDirection.Ltr) + assertThat(zoomDirection).isEqualTo(LayoutDirection.Ltr) + assertThat(quickSettingsDirection).isEqualTo(LayoutDirection.Ltr) + + layoutDirection = LayoutDirection.Rtl + composeTestRule.waitForIdle() + + val rtlRail = boundsOf(START_SIDE_RAIL_TAG) + val rtlIndicator = boundsOf(VIDEO_QUALITY_TAG) + val rtlZoom = boundsOf(ZOOM_BUTTON_ROW_TAG) + val rtlQuickSettings = boundsOf(QUICK_SETTINGS_DROP_DOWN) + + assertThat(rtlIndicator.top).isAtLeast(rtlRail.top) + assertThat(rtlIndicator.bottom).isAtMost(rtlZoom.top) + assertThat(rtlZoom.bottom).isAtMost(rtlQuickSettings.top) + assertThat(rtlQuickSettings.bottom).isAtMost(rtlRail.bottom) + + // Vertical coordinates must remain identical under RTL while child slots inherit RTL. + assertThat(rtlIndicator.top.value).isWithin(0.5f).of(ltrIndicator.top.value) + assertThat(rtlZoom.top.value).isWithin(0.5f).of(ltrZoom.top.value) + assertThat(rtlQuickSettings.top.value).isWithin(0.5f).of(ltrQuickSettings.top.value) + assertThat(indicatorDirection).isEqualTo(LayoutDirection.Rtl) + assertThat(zoomDirection).isEqualTo(LayoutDirection.Rtl) + assertThat(quickSettingsDirection).isEqualTo(LayoutDirection.Rtl) + } + + @Test + fun endSideRail_ltrAndRtl_preservesStrictTopToBottomShutterAndModeToggleOrder() { + var layoutDirection by mutableStateOf(LayoutDirection.Ltr) + var modeToggleDirection: LayoutDirection? = null + + val baseSlots = PlaceholderCaptureSlots.sideRail( + includeImageWell = true, + includePauseSlot = true, + includeElapsedTime = true + ) + val instrumentedSlots = baseSlots.copy( + captureModeToggle = { mod -> + modeToggleDirection = LocalLayoutDirection.current + baseSlots.captureModeToggle(this, mod) + } + ) + + composeTestRule.setContent { + CompositionLocalProvider(LocalLayoutDirection provides layoutDirection) { + EndSideRail( + slots = instrumentedSlots, + modifier = Modifier.height(800.dp), + isRecording = true, + showElapsedTimeInRail = true + ) + } + } + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithTag(END_SIDE_RAIL_TAG).assertIsDisplayed() + val orderedTags = listOf( + ELAPSED_TIME_TAG, + FLIP_CAMERA_BUTTON, + CAPTURE_BUTTON, + IMAGE_WELL_TAG, + AUDIO_INPUT_TOGGLE, + CAPTURE_MODE_TOGGLE_BUTTON + ) + + fun assertStrictVerticalSequence(contextLabel: String): List { + val railBounds = boundsOf(END_SIDE_RAIL_TAG) + val slotBounds = orderedTags.map { tag -> + boundsOf(tag).also { rect -> + assertWithMessage("$contextLabel: $tag should have positive height") + .that(rect.height) + .isGreaterThan(0.dp) + } + } + assertWithMessage("$contextLabel: top item inside rail") + .that(slotBounds.first().top) + .isAtLeast(railBounds.top) + for (i in 0 until slotBounds.lastIndex) { + assertWithMessage( + "$contextLabel: ${orderedTags[i]} must sit strictly above ${orderedTags[i + 1]}" + ) + .that(slotBounds[i].bottom) + .isAtMost(slotBounds[i + 1].top) + } + assertWithMessage("$contextLabel: bottom item inside rail") + .that(slotBounds.last().bottom) + .isAtMost(railBounds.bottom) + return slotBounds + } + + val ltrBounds = assertStrictVerticalSequence("LTR") + assertThat(modeToggleDirection).isEqualTo(LayoutDirection.Ltr) + + layoutDirection = LayoutDirection.Rtl + composeTestRule.waitForIdle() + + val rtlBounds = assertStrictVerticalSequence("RTL") + assertThat(modeToggleDirection).isEqualTo(LayoutDirection.Rtl) + ltrBounds.zip(rtlBounds).forEachIndexed { index, (ltrRect, rtlRect) -> + assertWithMessage("Vertical top for ${orderedTags[index]} should match in LTR and RTL") + .that(rtlRect.top.value) + .isWithin(0.5f) + .of(ltrRect.top.value) + } + } + + @Test + fun sideRails_compactHeightMode_fitsWithin360dpWithoutClippingOrOverlap() { + val compactSlots = PlaceholderCaptureSlots.sideRail( + isCompactHeight = true, + includeImageWell = true, + includePauseSlot = true, + includeElapsedTime = true + ) + + composeTestRule.setContent { + Row(modifier = Modifier.height(360.dp)) { + StartSideRail( + slots = compactSlots, + modifier = Modifier.height(360.dp), + isCompactHeight = true, + isRecording = true + ) + EndSideRail( + slots = compactSlots, + modifier = Modifier.height(360.dp), + isCompactHeight = true, + isRecording = true, + showElapsedTimeInRail = true + ) + } + } + composeTestRule.waitForIdle() + + val startRailTags = listOf( + VIDEO_QUALITY_TAG, + ZOOM_BUTTON_ROW_TAG, + QUICK_SETTINGS_DROP_DOWN + ) + val endRailTags = listOf( + ELAPSED_TIME_TAG, + FLIP_CAMERA_BUTTON, + CAPTURE_BUTTON, + IMAGE_WELL_TAG, + AUDIO_INPUT_TOGGLE, + CAPTURE_MODE_TOGGLE_BUTTON + ) + + fun verifyRailFitsIn360dp(railLabel: String, tags: List) { + val unclippedList = tags.map { tag -> + val clipped = composeTestRule.onNodeWithTag(tag).getBoundsInRoot() + val unclipped = composeTestRule.onNodeWithTag(tag).getUnclippedBoundsInRoot() + assertWithMessage("$railLabel: $tag must have non-zero size") + .that(unclipped.height) + .isGreaterThan(0.dp) + assertWithMessage("$railLabel: $tag must have non-zero width") + .that(unclipped.width) + .isGreaterThan(0.dp) + assertWithMessage("$railLabel: $tag top must be >= 0dp") + .that(unclipped.top.value) + .isAtLeast(-0.5f) + assertWithMessage("$railLabel: $tag bottom must be <= 360dp") + .that(unclipped.bottom.value) + .isAtMost(360.5f) + assertWithMessage("$railLabel: $tag should not be clipped vertically") + .that(clipped.height.value) + .isWithin(0.5f) + .of(unclipped.height.value) + unclipped + } + for (i in 0 until unclippedList.lastIndex) { + assertWithMessage( + "$railLabel: ${tags[i]} bottom (${unclippedList[i].bottom}) must not overlap " + + "${tags[i + 1]} top (${unclippedList[i + 1].top})" + ) + .that(unclippedList[i].bottom.value) + .isAtMost(unclippedList[i + 1].top.value + 0.5f) + } + } + + verifyRailFitsIn360dp("StartSideRail", startRailTags) + verifyRailFitsIn360dp("EndSideRail", endRailTags) + } + + @Test + fun endSideRail_omittedOptionalSlots_collapsesSpacesCleanly() { + var includePause by mutableStateOf(true) + + composeTestRule.setContent { + val slots = PlaceholderCaptureSlots.sideRail( + isCompactHeight = false, + includeImageWell = false, + includePauseSlot = includePause, + includeElapsedTime = false + ) + EndSideRail( + slots = slots, + modifier = Modifier.height(800.dp), + isCompactHeight = false, + showElapsedTimeInRail = false + ) + } + composeTestRule.waitForIdle() + + // Omitted middle slot (imageWell) must not exist and must not leave a double 96dp gap + // between captureButton and pauseToggleButton. + composeTestRule.onNodeWithTag(IMAGE_WELL_TAG).assertDoesNotExist() + composeTestRule.onNodeWithTag(ELAPSED_TIME_TAG).assertDoesNotExist() + + val flipBoundsWithPause = boundsOf(FLIP_CAMERA_BUTTON) + val captureBoundsWithPause = boundsOf(CAPTURE_BUTTON) + val pauseBounds = boundsOf(AUDIO_INPUT_TOGGLE) + + val flipToCaptureGap = captureBoundsWithPause.top - flipBoundsWithPause.bottom + val captureToPauseGap = pauseBounds.top - captureBoundsWithPause.bottom + assertThat(flipToCaptureGap.value).isWithin(1f).of(48f) + assertThat(captureToPauseGap.value).isWithin(1f).of(48f) + + // Omitting trailing pause slot as well leaves only Flip + Capture separated by 48dp, + // centered cleanly in the 800dp rail with zero phantom trailing space. + includePause = false + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithTag(AUDIO_INPUT_TOGGLE).assertDoesNotExist() + val railBounds = boundsOf(END_SIDE_RAIL_TAG) + val flipBounds = boundsOf(FLIP_CAMERA_BUTTON) + val captureBounds = boundsOf(CAPTURE_BUTTON) + assertThat((captureBounds.top - flipBounds.bottom).value).isWithin(1f).of(48f) + + val clusterCenterY = (flipBounds.top.value + captureBounds.bottom.value) / 2f + val railCenterY = (railBounds.top.value + railBounds.bottom.value) / 2f + assertThat(clusterCenterY).isWithin(1f).of(railCenterY) + } + + @Test + fun startSideRail_omittedIndicatorAndQuickSettings_keepsZoomCentered() { + val defaultSlots = PlaceholderCaptureSlots.default() + val zoomOnlySlots = CaptureSlots( + zoomControls = defaultSlots.zoomControls + ) + + composeTestRule.setContent { + StartSideRail( + slots = zoomOnlySlots, + modifier = Modifier.height(800.dp) + ) + } + composeTestRule.waitForIdle() + + composeTestRule.onNodeWithTag(VIDEO_QUALITY_TAG).assertDoesNotExist() + composeTestRule.onNodeWithTag(QUICK_SETTINGS_DROP_DOWN).assertDoesNotExist() + + val railBounds = boundsOf(START_SIDE_RAIL_TAG) + val zoomBounds = boundsOf(ZOOM_BUTTON_ROW_TAG) + val railCenterY = (railBounds.top.value + railBounds.bottom.value) / 2f + val zoomCenterY = (zoomBounds.top.value + zoomBounds.bottom.value) / 2f + + assertThat(zoomCenterY).isWithin(1f).of(railCenterY) + } + + @Test + fun endSideRail_customShutterStackOverride_invokesOverrideWithCompactFlag() { + var isCompactHeight by mutableStateOf(false) + var observedCompactParam: Boolean? = null + var observedScopeCompact: Boolean? = null + + val baseSlots = PlaceholderCaptureSlots.sideRail() + val overrideSlots = baseSlots.copy( + shutterStack = { mod, compactFlag -> + observedCompactParam = compactFlag + observedScopeCompact = isCompact + PlaceholderControlBox( + label = "CustomShutter", + width = 64.dp, + height = 140.dp, + modifier = mod, + testTag = IMAGE_WELL_TAG + ) + } + ) + + composeTestRule.setContent { + EndSideRail( + slots = overrideSlots, + modifier = Modifier.height(800.dp), + isCompactHeight = isCompactHeight + ) + } + composeTestRule.waitForIdle() + + // Default VerticalRailShutterCluster must be bypassed when shutterStack is provided. + composeTestRule.onNodeWithTag(CAPTURE_BUTTON).assertDoesNotExist() + composeTestRule.onNodeWithTag(FLIP_CAMERA_BUTTON).assertDoesNotExist() + composeTestRule.onNodeWithTag(IMAGE_WELL_TAG).assertIsDisplayed() + assertThat(observedCompactParam).isFalse() + assertThat(observedScopeCompact).isFalse() + + isCompactHeight = true + composeTestRule.waitForIdle() + + assertThat(observedCompactParam).isTrue() + assertThat(observedScopeCompact).isTrue() + } + + @Test + fun sideRails_recordingScope_propagatesOrientationCompactIsRecordingAndDismissToSlots() { + var isCompactHeight by mutableStateOf(false) + var isRecording by mutableStateOf(false) + var dismissCallCount = 0 + + var startOrientation: Orientation? = null + var startCompact: Boolean? = null + var startRecording: Boolean? = null + + var endOrientation: Orientation? = null + var endCompact: Boolean? = null + var endRecording: Boolean? = null + + val scopedSlots = CaptureSlots( + quickSettingsButton = { mod -> + startOrientation = orientation + startCompact = isCompact + startRecording = this.isRecording + Box( + modifier = mod + .size(48.dp) + .testTag(QUICK_SETTINGS_DROP_DOWN) + .clickable { dismissQuickSettings() } + ) + }, + captureButton = { mod -> + endOrientation = orientation + endCompact = isCompact + endRecording = this.isRecording + Box( + modifier = mod + .size(72.dp) + .testTag(CAPTURE_BUTTON) + .clickable { dismissQuickSettings() } + ) + } + ) + + composeTestRule.setContent { + Row(modifier = Modifier.height(800.dp)) { + StartSideRail( + slots = scopedSlots, + modifier = Modifier.height(800.dp), + isCompactHeight = isCompactHeight, + isRecording = isRecording, + onDismissQuickSettings = { dismissCallCount++ } + ) + EndSideRail( + slots = scopedSlots, + modifier = Modifier.height(800.dp), + isCompactHeight = isCompactHeight, + isRecording = isRecording, + onDismissQuickSettings = { dismissCallCount++ } + ) + } + } + composeTestRule.waitForIdle() + + assertThat(startOrientation).isEqualTo(Orientation.Vertical) + assertThat(endOrientation).isEqualTo(Orientation.Vertical) + assertThat(startCompact).isFalse() + assertThat(endCompact).isFalse() + assertThat(startRecording).isFalse() + assertThat(endRecording).isFalse() + + isCompactHeight = true + isRecording = true + composeTestRule.waitForIdle() + + assertThat(startCompact).isTrue() + assertThat(endCompact).isTrue() + assertThat(startRecording).isTrue() + assertThat(endRecording).isTrue() + + composeTestRule.onNodeWithTag(QUICK_SETTINGS_DROP_DOWN).performClick() + composeTestRule.waitForIdle() + assertThat(dismissCallCount).isEqualTo(1) + + composeTestRule.onNodeWithTag(CAPTURE_BUTTON).performClick() + composeTestRule.waitForIdle() + assertThat(dismissCallCount).isEqualTo(2) + } +}