diff --git a/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/MediaCaptureModeBar.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/MediaCaptureModeBar.kt index 2a3c2c4484..6aa86ad012 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/MediaCaptureModeBar.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/MediaCaptureModeBar.kt @@ -46,6 +46,7 @@ import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch @@ -79,12 +80,16 @@ private val MODE_SETTLE_SPEC = spring(dampingRatio = Spring.DampingRatioN * The highlight is fixed to the center of the screen and the bar slides so the selected mode is the one under it. A mode * can be picked by tapping it or by swiping the bar until it is under the highlight; a swipe only selects once it is * released, so the user can slide back and forth before committing. + * + * @param endReservation Width at the end of the row the bar stops short of claiming, so taps there reach whatever floats + * over it. Layout still uses the full row, so this moves nothing. */ @Composable internal fun MediaCaptureModeBar( availableCaptureModes: List, selectedCaptureMode: MediaCaptureMode, onEvent: (MediaCaptureScreenEvents) -> Unit, + endReservation: Dp = 0.dp, modifier: Modifier = Modifier ) { val coroutineScope = rememberCoroutineScope() @@ -169,18 +174,19 @@ internal fun MediaCaptureModeBar( val modes = measurables.drop(2).map { it.measure(Constraints(maxWidth = modeWidthLimit, minHeight = modeHeight, maxHeight = modeHeight)) } val measuredModeWidth = modes.maxOf { it.width } val barWidth = measuredModeWidth * modes.size + barPadding * 2 - val containerWidth = if (constraints.hasBoundedWidth) constraints.maxWidth else barWidth + val rowWidth = if (constraints.hasBoundedWidth) constraints.maxWidth else barWidth + val containerWidth = (rowWidth - endReservation.roundToPx()).coerceAtLeast(0) val bar = measurables[0].measure(Constraints.fixed(barWidth, barHeight)) val highlight = measurables[1].measure(Constraints.fixed(measuredModeWidth, modeHeight)) layout(width = containerWidth, height = barHeight) { - // The bar slides by however far the centered mode is from the middle of the container, which is where the - // highlight always sits. - val slide = containerWidth / 2f - barPadding - (centeredMode.value + 0.5f) * measuredModeWidth + // The bar slides by however far the centered mode is from the middle of the row, which is where the highlight + // always sits. + val slide = rowWidth / 2f - barPadding - (centeredMode.value + 0.5f) * measuredModeWidth bar.placeRelative(x = slide.roundToInt(), y = 0) - highlight.placeRelative(x = (containerWidth - measuredModeWidth) / 2, y = barPadding) + highlight.placeRelative(x = (rowWidth - measuredModeWidth) / 2, y = barPadding) modes.forEachIndexed { index, mode -> mode.placeRelative( diff --git a/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/MediaCaptureScreen.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/MediaCaptureScreen.kt index 3f817b13b7..0564f35408 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/MediaCaptureScreen.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/capture/MediaCaptureScreen.kt @@ -103,6 +103,9 @@ internal fun MediaCaptureScreen( * centered, and the button floats over its end. * * The row keeps a fixed height so neither one moving in or out shifts the other. + * + * The text story editor floats its own send button over that same end from underneath, so the bar is held off that + * corner while the editor is up. */ @Composable private fun MediaCaptureBottomControls( @@ -134,13 +137,14 @@ private fun MediaCaptureBottomControls( enter = fadeIn(animationSpec = tween(durationMillis = CONTROL_FADE_DURATION_MS)), exit = fadeOut(animationSpec = tween(durationMillis = CONTROL_FADE_DURATION_MS)), modifier = Modifier - .align(Alignment.BottomCenter) + .align(Alignment.BottomStart) .padding(bottom = MODE_BAR_BOTTOM_INSET) ) { MediaCaptureModeBar( availableCaptureModes = state.availableCaptureModes, selectedCaptureMode = state.selectedCaptureMode, onEvent = onEvent, + endReservation = if (state.selectedCaptureScreen is MediaSendRoute.Capture.TextStory) endMargin + NEXT_BUTTON_TOUCH_TARGET else 0.dp, modifier = Modifier.endFadingEdge( fadeWidth = MODE_BAR_FADE_WIDTH * fadeFraction, inset = (endMargin + NEXT_BUTTON_CIRCLE_SIZE) * fadeFraction diff --git a/feature/media-send/src/test/java/org/signal/mediasend/screens/capture/MediaCaptureScreenTest.kt b/feature/media-send/src/test/java/org/signal/mediasend/screens/capture/MediaCaptureScreenTest.kt index 26369881fa..ab16e1e3b6 100644 --- a/feature/media-send/src/test/java/org/signal/mediasend/screens/capture/MediaCaptureScreenTest.kt +++ b/feature/media-send/src/test/java/org/signal/mediasend/screens/capture/MediaCaptureScreenTest.kt @@ -6,8 +6,13 @@ package org.signal.mediasend.screens.capture import android.app.Application +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.platform.testTag @@ -21,12 +26,14 @@ import androidx.compose.ui.test.performTouchInput import androidx.compose.ui.test.swipeLeft import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.DpRect +import androidx.compose.ui.unit.dp import androidx.core.net.toUri import androidx.test.core.app.ApplicationProvider import assertk.assertThat import assertk.assertions.containsExactly import assertk.assertions.isCloseTo import assertk.assertions.isEmpty +import assertk.assertions.isEqualTo import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -74,10 +81,10 @@ class MediaCaptureScreenTest { fun `Given a flow that offers every mode, when displayed, then the selected one is centered under the highlight`() { setContent(cameraFirstState()) - val bar = composeTestRule.onNodeWithTag(TestTags.MEDIA_CAPTURE_MODE_BAR).getUnclippedBoundsInRoot() + val screen = composeTestRule.onNodeWithTag(TestTags.MEDIA_CAPTURE_SCREEN).getUnclippedBoundsInRoot() val selected = composeTestRule.onNodeWithTag(TestTags.MEDIA_CAPTURE_TEXT_STORY_TOGGLE).getUnclippedBoundsInRoot() - assertThat(selected.centerX.value).isCloseTo(bar.centerX.value, 1f) + assertThat(selected.centerX.value).isCloseTo(screen.centerX.value, 1f) } @Test @@ -192,6 +199,28 @@ class MediaCaptureScreenTest { composeTestRule.onNodeWithTag(TestTags.MEDIA_CAPTURE_PHOTO_TOGGLE).assertIsDisplayed() } + @Test + fun `Given the text story route, when the editor's send button is tapped, then the tap reaches the editor`() { + var sendClicks = 0 + + setContent(cameraFirstState()) { + Box(modifier = Modifier.fillMaxSize()) { + Box( + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(end = TEXT_STORY_SEND_END_MARGIN, bottom = TEXT_STORY_SEND_BOTTOM_MARGIN) + .size(TEXT_STORY_SEND_SIZE) + .clickable { sendClicks++ } + .testTag(TEXT_STORY_SEND) + ) + } + } + + composeTestRule.onNodeWithTag(TEXT_STORY_SEND).performClick() + + assertThat(sendClicks).isEqualTo(1) + } + //region The next button over the bar's end @Test @@ -251,19 +280,22 @@ class MediaCaptureScreenTest { mode = MediaSendFlowActivityContract.Mode.ChooseAfterMediaSelection ) - private fun setContent(state: MediaCaptureState) { + private fun setContent( + state: MediaCaptureState, + textStoryEditorSlot: @Composable () -> Unit = { + Box( + modifier = Modifier + .fillMaxSize() + .testTag(TEXT_STORY_SLOT) + ) + } + ) { composeTestRule.setContent { SignalTheme { MediaCaptureScreen( state = state, onEvent = { events += it }, - textStoryEditorSlot = { - Box( - modifier = Modifier - .fillMaxSize() - .testTag(TEXT_STORY_SLOT) - ) - } + textStoryEditorSlot = textStoryEditorSlot ) } } @@ -273,6 +305,11 @@ class MediaCaptureScreenTest { private companion object { private const val TEXT_STORY_SLOT = "text_story_slot" + private const val TEXT_STORY_SEND = "text_story_send" + + private val TEXT_STORY_SEND_SIZE = 48.dp + private val TEXT_STORY_SEND_END_MARGIN = 12.dp + private val TEXT_STORY_SEND_BOTTOM_MARGIN = 16.dp private val MEDIA = Media( uri = "content://capture".toUri(),