MediaSelectScreen rework.

This commit is contained in:
Alex Hart 2026-06-17 15:44:02 -03:00 committed by Greyson Parrelli
parent a7b4a5d93d
commit aecd17b2f0
15 changed files with 167 additions and 72 deletions

View File

@ -8,13 +8,16 @@ package org.signal.core.models.media
import android.net.Uri
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
import org.signal.core.models.UriSerializer
/**
* Represents a folder that's shown in a media selector, containing [Media] items.
*/
@Parcelize
@Serializable
data class MediaFolder(
val thumbnailUri: Uri,
@Serializable(with = UriSerializer::class) val thumbnailUri: Uri,
val title: String,
val itemCount: Int,
val bucketId: String,

View File

@ -13,6 +13,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import org.signal.core.ui.compose.Buttons
@ -23,15 +24,14 @@ import org.signal.core.ui.compose.Buttons
@Composable
fun MediaCaptureScreen(
backStack: NavBackStack<NavKey>,
onEvent: (MediaCaptureScreenEvent) -> Unit,
cameraSlot: @Composable () -> Unit,
textStoryEditorSlot: @Composable () -> Unit
) {
Box(modifier = Modifier.fillMaxSize()) {
val top = backStack.last()
when (top) {
is MediaSendNavKey.Capture.Camera -> cameraSlot()
when (backStack.last()) {
is MediaSendNavKey.Capture.TextStory -> textStoryEditorSlot()
else -> cameraSlot()
}
Row(
@ -39,20 +39,12 @@ fun MediaCaptureScreen(
.fillMaxWidth()
.align(Alignment.BottomCenter)
) {
Buttons.Small(onClick = {
if (top == MediaSendNavKey.Capture.TextStory) {
backStack.remove(top)
}
}) {
Text(text = "Camera")
Buttons.Small(onClick = { onEvent(MediaCaptureScreenEvent.ShowCamera) }) {
Text(text = stringResource(R.string.MediaCaptureScreen__camera))
}
Buttons.Small(onClick = {
if (top == MediaSendNavKey.Capture.Camera) {
backStack.add(MediaSendNavKey.Capture.TextStory)
}
}) {
Text(text = "Text Story")
Buttons.Small(onClick = { onEvent(MediaCaptureScreenEvent.ShowTextStory) }) {
Text(text = stringResource(R.string.MediaCaptureScreen__text_story))
}
}
}

View File

@ -0,0 +1,11 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.mediasend
sealed interface MediaCaptureScreenEvent {
data object ShowCamera : MediaCaptureScreenEvent
data object ShowTextStory : MediaCaptureScreenEvent
}

View File

@ -11,10 +11,12 @@ import org.signal.mediasend.select.MediaSelectScreenEvent
interface MediaSendEventHandler {
fun onMediaSelectScreenEvent(mediaSelectScreenEvent: MediaSelectScreenEvent)
fun onMediaEditScreenEvent(mediaEditScreenEvent: MediaEditScreenEvent)
fun onMediaCaptureScreenEvent(mediaCaptureScreenEvent: MediaCaptureScreenEvent)
object Empty : MediaSendEventHandler {
override fun onMediaSelectScreenEvent(mediaSelectScreenEvent: MediaSelectScreenEvent) = Unit
override fun onMediaEditScreenEvent(mediaEditScreenEvent: MediaEditScreenEvent) = Unit
override fun onMediaCaptureScreenEvent(mediaCaptureScreenEvent: MediaCaptureScreenEvent) = Unit
}
}

View File

@ -6,6 +6,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@ -22,6 +23,7 @@ import org.signal.core.ui.compose.AllDevicePreviews
import org.signal.core.ui.compose.Previews
import org.signal.mediasend.edit.MediaEditScreen
import org.signal.mediasend.select.MediaSelectScreen
import org.signal.mediasend.select.MediaSelectScreenState
/**
* Enforces the following flow of:
@ -48,16 +50,39 @@ fun MediaSendNavDisplay(
is MediaSendNavKey.Capture -> NavEntry(MediaSendNavKey.Capture.Chrome) {
MediaCaptureScreen(
backStack = backStack,
onEvent = eventHandler::onMediaCaptureScreenEvent,
cameraSlot = cameraSlot,
textStoryEditorSlot = textStoryEditorSlot
)
}
MediaSendNavKey.Select -> NavEntry(key) {
MediaSendNavKey.Select.Folders -> NavEntry(key) {
val state by stateFlow.collectAsStateWithLifecycle()
val screenState = remember(state.mediaFolders, state.selectedMedia) {
MediaSelectScreenState.Folders(
mediaFolders = state.mediaFolders,
selectedMedia = state.selectedMedia
)
}
MediaSelectScreen(
state = state,
backStack = backStack,
state = screenState,
onEvent = eventHandler::onMediaSelectScreenEvent
)
}
is MediaSendNavKey.Select.Files -> NavEntry(key) {
val state by stateFlow.collectAsStateWithLifecycle()
val screenState = remember(state.selectedMedia, state.selectedMediaFolderItems) {
MediaSelectScreenState.Files(
selectedMediaFolder = key.folder,
selectedMediaFolderItems = state.selectedMediaFolderItems,
selectedMedia = state.selectedMedia
)
}
MediaSelectScreen(
state = screenState,
onEvent = eventHandler::onMediaSelectScreenEvent
)
}
@ -66,7 +91,6 @@ fun MediaSendNavDisplay(
val state by stateFlow.collectAsStateWithLifecycle()
MediaEditScreen(
state = state,
backStack = backStack,
videoEditorSlot = videoEditorSlot,
onEvent = eventHandler::onMediaEditScreenEvent
)

View File

@ -7,6 +7,7 @@ package org.signal.mediasend
import androidx.navigation3.runtime.NavKey
import kotlinx.serialization.Serializable
import org.signal.core.models.media.MediaFolder
/**
* Nav3 keys
@ -14,7 +15,13 @@ import kotlinx.serialization.Serializable
@Serializable
sealed interface MediaSendNavKey : NavKey {
@Serializable
data object Select : MediaSendNavKey
sealed interface Select : MediaSendNavKey {
@Serializable
data object Folders : Select
@Serializable
data class Files(val folder: MediaFolder) : Select
}
@Serializable
sealed interface Capture : MediaSendNavKey {

View File

@ -10,11 +10,8 @@ import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigationevent.NavigationEventDispatcherOwner
import androidx.navigationevent.compose.LocalNavigationEventDispatcherOwner
import org.signal.core.ui.compose.theme.SignalTheme
@ -31,11 +28,6 @@ fun MediaSendScreen(
) {
val viewModel = viewModel<MediaSendViewModel>(factory = MediaSendViewModel.Factory(args = contractArgs))
val state by viewModel.state.collectAsStateWithLifecycle()
val backStack = rememberNavBackStack(
if (state.isCameraFirst) MediaSendNavKey.Capture.Camera else MediaSendNavKey.Select
)
LaunchedEffect(viewModel) {
viewModel.hudCommands.collect { command ->
onExternalHudCommand(command)
@ -47,7 +39,7 @@ fun MediaSendScreen(
Surface {
MediaSendNavDisplay(
stateFlow = viewModel.state,
backStack = backStack,
backStack = viewModel.backStack,
eventHandler = viewModel,
modifier = modifier,
cameraSlot = cameraSlot,

View File

@ -11,8 +11,13 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.createSavedStateHandle
import androidx.lifecycle.serialization.saved
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.CreationExtras
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.serialization.NavBackStackSerializer
import androidx.navigation3.runtime.serialization.NavKeySerializer
import com.bumptech.glide.load.DataSource
import com.bumptech.glide.load.engine.GlideException
import com.bumptech.glide.request.RequestListener
@ -75,6 +80,13 @@ class MediaSendViewModel(
sendType = args.sendType
)
val backStack: NavBackStack<NavKey> by savedStateHandle.saved(
serializer = NavBackStackSerializer(NavKeySerializer()),
key = KEY_BACK_STACK
) {
NavBackStack(if (args.isCameraFirst) MediaSendNavKey.Capture.Camera else MediaSendNavKey.Select.Folders)
}
/**
* Main UI state. Backed by [SavedStateHandle] for automatic process death survival.
* Writes to this flow are automatically persisted.
@ -155,12 +167,21 @@ class MediaSendViewModel(
is MediaSelectScreenEvent.FolderClick -> onFolderClick(mediaSelectScreenEvent.mediaFolder)
is MediaSelectScreenEvent.MediaClick -> onMediaClick(mediaSelectScreenEvent.media)
is MediaSelectScreenEvent.SetFocusedMedia -> setFocusedMedia(mediaSelectScreenEvent.media)
MediaSelectScreenEvent.NavigateToEdit -> backStack.goToEdit()
}
}
override fun onMediaCaptureScreenEvent(mediaCaptureScreenEvent: MediaCaptureScreenEvent) {
when (mediaCaptureScreenEvent) {
MediaCaptureScreenEvent.ShowCamera -> backStack.goToCamera()
MediaCaptureScreenEvent.ShowTextStory -> backStack.goToTextStory()
}
}
override fun onMediaEditScreenEvent(mediaEditScreenEvent: MediaEditScreenEvent) {
when (mediaEditScreenEvent) {
is MediaEditScreenEvent.FocusedMediaChanged -> setFocusedMedia(mediaEditScreenEvent.media)
MediaEditScreenEvent.NavigateToSend -> backStack.goToSend()
is MediaEditScreenEvent.AddMessageClick -> {
val snapshot: MediaSendState = state.value
@ -176,6 +197,10 @@ class MediaSendViewModel(
}
private fun onFolderClick(mediaFolder: MediaFolder?) {
if (mediaFolder != null) {
backStack.goToFiles(mediaFolder)
}
viewModelScope.launch {
if (mediaFolder != null) {
val media = repository.getMedia(mediaFolder.bucketId)
@ -753,6 +778,7 @@ class MediaSendViewModel(
private const val KEY_IDENTITY_CHANGES_SINCE = "media_send_vm_identity_changes_since"
private const val KEY_STATE = "media_send_vm_state"
private const val KEY_EDITED_VIDEO_URIS = "media_send_vm_edited_video_uris"
private const val KEY_BACK_STACK = "media_send_vm_back_stack"
}
/**

View File

@ -7,6 +7,7 @@ package org.signal.mediasend
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import org.signal.core.models.media.MediaFolder
internal fun NavBackStack<NavKey>.goToEdit() {
if (contains(MediaSendNavKey.Edit)) {
@ -24,6 +25,20 @@ internal fun NavBackStack<NavKey>.goToSend() {
}
}
internal fun NavBackStack<NavKey>.goToFiles(mediaFolder: MediaFolder) {
add(MediaSendNavKey.Select.Files(mediaFolder))
}
internal fun NavBackStack<NavKey>.goToTextStory() {
if (!contains(MediaSendNavKey.Capture.TextStory)) {
add(MediaSendNavKey.Capture.TextStory)
}
}
internal fun NavBackStack<NavKey>.goToCamera() {
remove(MediaSendNavKey.Capture.TextStory)
}
internal fun NavBackStack<NavKey>.pop() {
if (isNotEmpty()) {
removeAt(size - 1)

View File

@ -25,9 +25,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.unit.dp
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.rememberNavBackStack
import kotlinx.coroutines.launch
import org.signal.core.ui.WindowBreakpoint
import org.signal.core.ui.compose.AllDevicePreviews
@ -35,15 +32,12 @@ import org.signal.core.ui.compose.Previews
import org.signal.core.ui.rememberWindowBreakpoint
import org.signal.imageeditor.core.model.EditorModel
import org.signal.mediasend.EditorState
import org.signal.mediasend.MediaSendNavKey
import org.signal.mediasend.MediaSendState
import org.signal.mediasend.goToSend
@Composable
fun MediaEditScreen(
state: MediaSendState,
onEvent: (MediaEditScreenEvent) -> Unit,
backStack: NavBackStack<NavKey>,
videoEditorSlot: @Composable () -> Unit = {}
) {
val scope = rememberCoroutineScope()
@ -143,7 +137,7 @@ fun MediaEditScreen(
AddAMessageRow(
message = state.message,
onEvent = onEvent,
onNextClick = { backStack.goToSend() },
onNextClick = { onEvent(MediaEditScreenEvent.NavigateToSend) },
modifier = Modifier
.widthIn(max = 624.dp)
.padding(horizontal = 16.dp)
@ -179,7 +173,6 @@ private fun MediaEditScreenPreview() {
)
),
onEvent = {},
backStack = rememberNavBackStack(MediaSendNavKey.Edit),
videoEditorSlot = {
Box(
modifier = Modifier

View File

@ -10,4 +10,5 @@ import org.signal.core.models.media.Media
sealed interface MediaEditScreenEvent {
data class FocusedMediaChanged(val media: Media) : MediaEditScreenEvent
data class AddMessageClick(val startWithEmojiKeyboard: Boolean = false) : MediaEditScreenEvent
data object NavigateToSend : MediaEditScreenEvent
}

View File

@ -5,6 +5,7 @@
package org.signal.mediasend.select
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
@ -50,15 +51,13 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.window.core.layout.WindowSizeClass
import org.signal.core.models.media.Media
import org.signal.core.models.media.MediaFolder
@ -70,11 +69,8 @@ import org.signal.core.ui.compose.Scaffolds
import org.signal.core.ui.compose.ensureWidthIsAtLeastHeight
import org.signal.glide.compose.GlideImage
import org.signal.mediasend.MediaSendMetrics
import org.signal.mediasend.MediaSendNavKey
import org.signal.mediasend.MediaSendState
import org.signal.mediasend.R
import org.signal.mediasend.edit.rememberPreviewMedia
import org.signal.mediasend.goToEdit
import org.signal.mediasend.pop
/**
* Allows user to select one or more pieces of content to add to the
@ -82,22 +78,19 @@ import org.signal.mediasend.pop
*/
@Composable
internal fun MediaSelectScreen(
state: MediaSendState,
backStack: NavBackStack<NavKey>,
state: MediaSelectScreenState,
onEvent: (MediaSelectScreenEvent) -> Unit
) {
val gridConfiguration = rememberGridConfiguration(state.selectedMediaFolder == null)
val gridConfiguration = rememberGridConfiguration(state is MediaSelectScreenState.Folders)
val backDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher
Scaffolds.Settings(
title = state.selectedMediaFolder?.title ?: "Gallery",
title = when (state) {
is MediaSelectScreenState.Folders -> stringResource(R.string.MediaSelectScreen__gallery)
is MediaSelectScreenState.Files -> state.selectedMediaFolder.title
},
navigationIcon = ImageVector.vectorResource(org.signal.core.ui.R.drawable.symbol_arrow_start_24),
onNavigationClick = {
if (state.selectedMediaFolder != null) {
onEvent(MediaSelectScreenEvent.FolderClick(null))
} else {
backStack.pop()
}
}
onNavigationClick = { backDispatcher?.onBackPressed() }
) { paddingValues ->
Column(
modifier = Modifier
@ -112,13 +105,17 @@ internal fun MediaSelectScreen(
.padding(horizontal = gridConfiguration.horizontalMargin)
.weight(1f)
) {
if (state.selectedMediaFolder == null) {
items(state.mediaFolders, key = { it.bucketId }) {
MediaFolderTile(it, onEvent)
when (state) {
is MediaSelectScreenState.Folders -> {
items(state.mediaFolders, key = { it.bucketId }) {
MediaFolderTile(it, onEvent)
}
}
} else {
items(state.selectedMediaFolderItems, key = { it.uri }) { media ->
MediaTile(media = media, state.selectedMedia.indexOfFirst { it.uri == media.uri }, onEvent = onEvent)
is MediaSelectScreenState.Files -> {
items(state.selectedMediaFolderItems, key = { it.uri }) { media ->
MediaTile(media = media, state.selectedMedia.indexOfFirst { it.uri == media.uri }, onEvent = onEvent)
}
}
}
}
@ -149,13 +146,13 @@ internal fun MediaSelectScreen(
items(state.selectedMedia, key = { it.uri }) { media ->
MediaThumbnail(media, modifier = Modifier.animateItem()) {
onEvent(MediaSelectScreenEvent.SetFocusedMedia(media))
backStack.goToEdit()
onEvent(MediaSelectScreenEvent.NavigateToEdit)
}
}
}
NextButton(state.selectedMedia.size) {
backStack.goToEdit()
onEvent(MediaSelectScreenEvent.NavigateToEdit)
}
}
}
@ -374,7 +371,7 @@ private fun NextButton(mediaSelectionCount: Int, onClick: () -> Unit) {
Icon(
imageVector = ImageVector.vectorResource(org.signal.core.ui.R.drawable.symbol_chevron_right_24),
contentDescription = "Next"
contentDescription = stringResource(R.string.MediaSelectScreen__next)
)
}
}
@ -407,10 +404,10 @@ private fun MediaThumbnail(
private fun MediaSelectScreenFolderPreview() {
Previews.Preview {
MediaSelectScreen(
state = MediaSendState(
mediaFolders = rememberPreviewMediaFolders(20)
state = MediaSelectScreenState.Folders(
mediaFolders = rememberPreviewMediaFolders(20),
selectedMedia = emptyList()
),
backStack = rememberNavBackStack(MediaSendNavKey.Edit),
onEvent = {}
)
}
@ -425,13 +422,11 @@ private fun MediaSelectScreenMediaPreview() {
Previews.Preview {
MediaSelectScreen(
state = MediaSendState(
mediaFolders = folders,
state = MediaSelectScreenState.Files(
selectedMediaFolder = folders.first(),
selectedMediaFolderItems = media,
selectedMedia = selectedMedia
),
backStack = rememberNavBackStack(MediaSendNavKey.Edit),
onEvent = {
if (it is MediaSelectScreenEvent.MediaClick) {
if (it.media in selectedMedia) {

View File

@ -12,4 +12,5 @@ sealed interface MediaSelectScreenEvent {
data class FolderClick(val mediaFolder: MediaFolder?) : MediaSelectScreenEvent
data class MediaClick(val media: Media) : MediaSelectScreenEvent
data class SetFocusedMedia(val media: Media) : MediaSelectScreenEvent
data object NavigateToEdit : MediaSelectScreenEvent
}

View File

@ -0,0 +1,25 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.mediasend.select
import org.signal.core.models.media.Media
import org.signal.core.models.media.MediaFolder
sealed interface MediaSelectScreenState {
val selectedMedia: List<Media>
data class Folders(
val mediaFolders: List<MediaFolder>,
override val selectedMedia: List<Media>
) : MediaSelectScreenState
data class Files(
val selectedMediaFolder: MediaFolder,
val selectedMediaFolderItems: List<Media>,
override val selectedMedia: List<Media>
) : MediaSelectScreenState
}

View File

@ -18,4 +18,12 @@
<string name="SentMediaQuality__high">High</string>
<!-- Setting option that can be selected to default media to be sent as standard quality by default -->
<string name="SentMediaQuality__standard">Standard</string>
<!-- Title of the screen where the user browses their device gallery to pick media to send. -->
<string name="MediaSelectScreen__gallery">Gallery</string>
<!-- Accessibility description for the button that advances from media selection to the next step in the send flow. -->
<string name="MediaSelectScreen__next">Next</string>
<!-- Label for the button that switches the capture screen to the camera. -->
<string name="MediaCaptureScreen__camera">Camera</string>
<!-- Label for the button that switches the capture screen to the text story editor. -->
<string name="MediaCaptureScreen__text_story">Text Story</string>
</resources>