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