diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendMetrics.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendMetrics.kt index cd930f89d2..16efe05e60 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendMetrics.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendMetrics.kt @@ -7,9 +7,12 @@ package org.signal.mediasend import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition +import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.Alignment import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp @@ -19,4 +22,11 @@ object MediaSendMetrics { val ControlEnterTransition: EnterTransition = fadeIn() val ControlExitTransition: ExitTransition = fadeOut() + + /** + * For the bottom-most control of a bottom-aligned stack: the control slides off the bottom edge while giving up its + * layout space at the same rate, so everything above it slides along with it as a unit. + */ + val SlidingControlEnterTransition: EnterTransition = ControlEnterTransition + expandVertically(expandFrom = Alignment.Top, clip = false) + val SlidingControlExitTransition: ExitTransition = ControlExitTransition + shrinkVertically(shrinkTowards = Alignment.Top, clip = false) } diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendViewModel.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendViewModel.kt index 47594af7f7..bd2541107b 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendViewModel.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendViewModel.kt @@ -242,6 +242,7 @@ class MediaSendViewModel( is MediaSelectScreenEvent.SetFocusedMedia -> setFocusedMedia(mediaSelectScreenEvent.media) is MediaSelectScreenEvent.ReorderSelectedMedia -> reorderMedia(mediaSelectScreenEvent.fromIndex, mediaSelectScreenEvent.toIndex) MediaSelectScreenEvent.NavigateToEdit -> backStack.goToEdit() + MediaSelectScreenEvent.NavigateToCamera -> backStack.goToCamera() } } @@ -251,8 +252,8 @@ class MediaSendViewModel( MediaCaptureScreenEvent.ShowTextStory -> backStack.goToTextStory() is MediaCaptureScreenEvent.Camera -> onCameraXScreenEvent(mediaCaptureScreenEvent.event) MediaCaptureScreenEvent.NextClicked -> backStack.goToEdit() - MediaCaptureScreenEvent.CycleTextStoryBackgroundColor -> Unit // TODO [media-send] - MediaCaptureScreenEvent.AddLinkToTextStory -> Unit // TODO [media-send] + MediaCaptureScreenEvent.CycleTextStoryBackgroundColor -> error("Handled directly in the fragment.") + MediaCaptureScreenEvent.AddLinkToTextStory -> error("Handled directly in the fragment.") } } diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreen.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreen.kt index bd573514b8..8a35b8e73b 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreen.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreen.kt @@ -5,8 +5,13 @@ package org.signal.mediasend.edit +import android.net.Uri import androidx.activity.compose.BackHandler import androidx.activity.compose.LocalActivity +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background import androidx.compose.foundation.gestures.snapping.SnapPosition import androidx.compose.foundation.layout.Arrangement.spacedBy @@ -51,6 +56,7 @@ import org.signal.glide.decryptableuri.DecryptableUri import org.signal.imageeditor.core.model.EditorModel import org.signal.mediasend.EditorState import org.signal.mediasend.MediaSendDependencies +import org.signal.mediasend.MediaSendMetrics import org.signal.mediasend.MediaSendState import org.signal.mediasend.edit.document.DocumentPage import org.signal.mediasend.edit.image.BrushWidthBar @@ -113,7 +119,8 @@ fun MediaEditScreen( var isVideoInteracting by remember(focusedUri) { mutableStateOf(false) } var isAdjustingBrushWidth by remember(focusedUri) { mutableStateOf(false) } - val isInteracting = imageController?.isUserInEdit == true || isVideoInteracting + val isImageEditing = imageController?.isUserInEdit == true + val isInteracting = isImageEditing || isVideoInteracting HorizontalPager( state = pagerState, @@ -218,7 +225,6 @@ fun MediaEditScreen( val isTextEditing = imageController?.textEditingElement != null Column( - verticalArrangement = spacedBy(20.dp), horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier .align(Alignment.BottomCenter) @@ -226,95 +232,71 @@ fun MediaEditScreen( .navigationBarsPadding() .then(if (isTextEditing) Modifier.imePadding() else Modifier) ) { - if (state.selectedMedia.size > 1 && !isInteracting) { - ThumbnailRow( - selectedMedia = state.selectedMedia, - pagerState = pagerState, - onFocusedMediaChange = { - onEvent(MediaEditScreenEvent.FocusedMediaChanged(it)) - }, - onThumbnailClick = { index -> - if (pagerState.currentPage == index) { - onEvent(MediaEditScreenEvent.RemoveMedia(state.selectedMedia[index])) - } else { - scope.launch { - pagerState.animateScrollToPage(index) - } - } - }, - onReorder = { fromIndex, toIndex -> - onEvent(MediaEditScreenEvent.ReorderSelectedMedia(fromIndex, toIndex)) - } - ) - } - - when (focusedEditorState) { - is EditorState.Image -> { - imageController?.let { controller -> - if (controller.mode == ImageController.Mode.CROP) { - RotationDial( - imageEditorController = controller, - modifier = Modifier - .widthIn(max = 380.dp) - .padding(horizontal = 16.dp) - ) - } - - if (controller.isUserDrawing) { - DrawModeColorBar(imageEditorController = controller) - } - - if (isSmallWindowBreakpoint) { - ImageEditorToolbar(imageEditorController = controller, state = state, onEvent = onEvent) - } - } - } - - is EditorState.VideoTrim -> { - val playbackPositionUs by produceState(focusedEditorState.videoTrimData.startTimeUs, focusedUri) { - videoEditorViewModel.events(focusedUri).collect { event -> - if (event is VideoEditorViewModel.Event.ActualPositionChanged) { - value = event.positionUs - } - } - } - - VideoEditorToolbar( + Column( + verticalArrangement = spacedBy(20.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (focusedEditorState is EditorState.VideoTrim) { + VideoTrimTimeline( videoUri = focusedUri, - mediaInputFactory = MediaSendDependencies.mediaInputFactory, - videoTrimData = focusedEditorState.videoTrimData, - maxSelectableDurationUs = focusedEditorState.maxDurationUs, - playbackPositionUs = playbackPositionUs, - onEvent = { event -> - when (event) { - is MediaEditScreenEvent.VideoTrimChanged -> { - isVideoInteracting = !event.editingComplete - onEvent(event) - } - - is MediaEditScreenEvent.VideoSeek -> { - isVideoInteracting = !event.editingComplete - videoEditorViewModel.sendCommand( - focusedUri, - if (event.editingComplete) { - VideoEditorViewModel.Command.EndPositionDrag(event.positionUs) - } else { - VideoEditorViewModel.Command.PositionDrag(event.positionUs) - } - ) - } - - else -> onEvent(event) - } - } + editorState = focusedEditorState, + videoEditorViewModel = videoEditorViewModel, + onInteractingChange = { isVideoInteracting = it }, + onEvent = onEvent ) } - is EditorState.Document, EditorState.VideoGif, EditorState.Gif, null -> Unit + if (state.selectedMedia.size > 1) { + MediaEditControl(visible = !isImageEditing, faded = isVideoInteracting) { + ThumbnailRow( + selectedMedia = state.selectedMedia, + pagerState = pagerState, + enabled = !isInteracting, + onFocusedMediaChange = { + onEvent(MediaEditScreenEvent.FocusedMediaChanged(it)) + }, + onThumbnailClick = { index -> + if (pagerState.currentPage == index) { + onEvent(MediaEditScreenEvent.RemoveMedia(state.selectedMedia[index])) + } else { + scope.launch { + pagerState.animateScrollToPage(index) + } + } + }, + onReorder = { fromIndex, toIndex -> + onEvent(MediaEditScreenEvent.ReorderSelectedMedia(fromIndex, toIndex)) + } + ) + } + } + + imageController?.let { controller -> + if (controller.mode == ImageController.Mode.CROP) { + RotationDial( + imageEditorController = controller, + modifier = Modifier + .widthIn(max = 380.dp) + .padding(horizontal = 16.dp) + ) + } + + if (controller.isUserDrawing) { + DrawModeColorBar(imageEditorController = controller) + } + + if (isSmallWindowBreakpoint) { + ImageEditorToolbar(imageEditorController = controller, state = state, onEvent = onEvent) + } + } } - val showAddMessageRow = !(isInteracting && focusedEditorState !is EditorState.VideoTrim) - if (showAddMessageRow) { + MediaEditControl( + visible = !isImageEditing, + faded = isVideoInteracting, + enter = MediaSendMetrics.SlidingControlEnterTransition, + exit = MediaSendMetrics.SlidingControlExitTransition + ) { AddAMessageRow( enabled = !isInteracting && !state.isSending, message = state.message, @@ -323,8 +305,8 @@ fun MediaEditScreen( modifier = Modifier .widthIn(max = 624.dp) .padding(horizontal = 16.dp) - .padding(bottom = 16.dp) - .alpha(if (isInteracting) 0f else 1f) + // Own padding rather than the stack's arrangement so the gap collapses along with the slide. + .padding(top = 20.dp, bottom = 16.dp) ) } } @@ -377,6 +359,82 @@ fun MediaEditScreen( } } +/** + * A control in the bottom stack, and the two ways it gets out of the user's way. Going not-[visible] gives up its layout + * space, letting the rest of the stack settle into it, while [faded] holds onto the space -- releasing it mid-gesture + * would move whatever the user is dragging out from under their finger. + */ +@Composable +private fun MediaEditControl( + visible: Boolean, + faded: Boolean, + enter: EnterTransition = MediaSendMetrics.ControlEnterTransition, + exit: ExitTransition = MediaSendMetrics.ControlExitTransition, + content: @Composable () -> Unit +) { + val alpha by animateFloatAsState(targetValue = if (faded) 0f else 1f) + + AnimatedVisibility( + visible = visible, + enter = enter, + exit = exit, + modifier = Modifier.alpha(alpha) + ) { + content() + } +} + +/** + * Trim/scrub timeline for the focused video. Drag state is reported through [onInteractingChange] so the rest of the + * stack can get out of the way, and seeks are translated into player commands rather than screen events. + */ +@Composable +private fun VideoTrimTimeline( + videoUri: Uri, + editorState: EditorState.VideoTrim, + videoEditorViewModel: VideoEditorViewModel, + onInteractingChange: (Boolean) -> Unit, + onEvent: (MediaEditScreenEvent) -> Unit +) { + val playbackPositionUs by produceState(editorState.videoTrimData.startTimeUs, videoUri) { + videoEditorViewModel.events(videoUri).collect { event -> + if (event is VideoEditorViewModel.Event.ActualPositionChanged) { + value = event.positionUs + } + } + } + + VideoEditorToolbar( + videoUri = videoUri, + mediaInputFactory = MediaSendDependencies.mediaInputFactory, + videoTrimData = editorState.videoTrimData, + maxSelectableDurationUs = editorState.maxDurationUs, + playbackPositionUs = playbackPositionUs, + onEvent = { event -> + when (event) { + is MediaEditScreenEvent.VideoTrimChanged -> { + onInteractingChange(!event.editingComplete) + onEvent(event) + } + + is MediaEditScreenEvent.VideoSeek -> { + onInteractingChange(!event.editingComplete) + videoEditorViewModel.sendCommand( + videoUri, + if (event.editingComplete) { + VideoEditorViewModel.Command.EndPositionDrag(event.positionUs) + } else { + VideoEditorViewModel.Command.PositionDrag(event.positionUs) + } + ) + } + + else -> onEvent(event) + } + } + ) +} + @Composable private fun rememberVideoEditorViewModel(): VideoEditorViewModel { return if (LocalInspectionMode.current) { diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/ThumbnailRow.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/ThumbnailRow.kt index 27fff34ce2..93923d3036 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/ThumbnailRow.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/ThumbnailRow.kt @@ -74,7 +74,8 @@ internal fun ThumbnailRow( pagerState: PagerState, onFocusedMediaChange: (Media) -> Unit, onThumbnailClick: (Int) -> Unit = {}, - onReorder: (fromIndex: Int, toIndex: Int) -> Unit = { _, _ -> } + onReorder: (fromIndex: Int, toIndex: Int) -> Unit = { _, _ -> }, + enabled: Boolean = true ) { val density = LocalDensity.current val scope = rememberCoroutineScope() @@ -159,7 +160,7 @@ internal fun ThumbnailRow( .draggable( state = draggableState, orientation = Orientation.Horizontal, - enabled = !isReordering, + enabled = enabled && !isReordering, onDragStopped = { velocity -> scope.launch { val targetPage = when { @@ -187,7 +188,7 @@ internal fun ThumbnailRow( contentPadding = PaddingValues(start = startPadding, end = endPadding), state = listState, userScrollEnabled = false, - modifier = Modifier.reorderableList(reorderableListState) + modifier = if (enabled) Modifier.reorderableList(reorderableListState) else Modifier ) { itemsIndexed(reorderBuffer.items, key = { _, media -> media.uri }) { index, media -> val padding by remember(index) { @@ -210,7 +211,7 @@ internal fun ThumbnailRow( media = media, modifier = Modifier .padding(horizontal = padding) - .clickable { onThumbnailClick(index) } + .clickable(enabled = enabled) { onThumbnailClick(index) } ) } } diff --git a/feature/media-send/src/main/java/org/signal/mediasend/select/MediaSelectScreen.kt b/feature/media-send/src/main/java/org/signal/mediasend/select/MediaSelectScreen.kt index a6804ad23d..eefdb55d98 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/select/MediaSelectScreen.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/select/MediaSelectScreen.kt @@ -38,6 +38,7 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo @@ -68,6 +69,7 @@ import org.signal.core.ui.compose.Buttons import org.signal.core.ui.compose.DayNightPreviews import org.signal.core.ui.compose.Previews import org.signal.core.ui.compose.Scaffolds +import org.signal.core.ui.compose.SignalIcons import org.signal.core.ui.compose.ensureWidthIsAtLeastHeight import org.signal.core.ui.compose.list.ReorderableItem import org.signal.core.ui.compose.list.rememberReorderBuffer @@ -96,7 +98,14 @@ internal fun MediaSelectScreen( is MediaSelectScreenState.Files -> state.selectedMediaFolder.title }, navigationIcon = ImageVector.vectorResource(org.signal.core.ui.R.drawable.symbol_arrow_start_24), - onNavigationClick = { backDispatcher?.onBackPressed() } + onNavigationClick = { backDispatcher?.onBackPressed() }, + actions = { + IconButton(onClick = { + onEvent(MediaSelectScreenEvent.NavigateToCamera) + }) { + Icon(imageVector = SignalIcons.Camera.imageVector, contentDescription = stringResource(R.string.MediaSelectScreen__go_to_camera)) + } + } ) { paddingValues -> Column( modifier = Modifier diff --git a/feature/media-send/src/main/java/org/signal/mediasend/select/MediaSelectScreenEvent.kt b/feature/media-send/src/main/java/org/signal/mediasend/select/MediaSelectScreenEvent.kt index 03d30677e1..c4c60e543f 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/select/MediaSelectScreenEvent.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/select/MediaSelectScreenEvent.kt @@ -14,4 +14,5 @@ sealed interface MediaSelectScreenEvent { data class SetFocusedMedia(val media: Media) : MediaSelectScreenEvent data class ReorderSelectedMedia(val fromIndex: Int, val toIndex: Int) : MediaSelectScreenEvent data object NavigateToEdit : MediaSelectScreenEvent + data object NavigateToCamera : MediaSelectScreenEvent } diff --git a/feature/media-send/src/main/res/values/strings.xml b/feature/media-send/src/main/res/values/strings.xml index b8e9d3eee7..90e156b127 100644 --- a/feature/media-send/src/main/res/values/strings.xml +++ b/feature/media-send/src/main/res/values/strings.xml @@ -32,6 +32,8 @@ Gallery Next + + Go to camera Camera