diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/NewConversationActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/NewConversationActivity.kt index 14fa8fde9a..c49a1ec870 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/NewConversationActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/NewConversationActivity.kt @@ -14,25 +14,17 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.widthIn -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text -import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi -import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext @@ -49,25 +41,20 @@ import org.signal.core.ui.compose.AllDevicePreviews import org.signal.core.ui.compose.Dialogs import org.signal.core.ui.compose.DropdownMenus import org.signal.core.ui.compose.Previews -import org.signal.core.ui.compose.Scaffolds import org.signal.core.ui.compose.theme.SignalTheme import org.thoughtcrime.securesms.BlockUnblockDialog import org.thoughtcrime.securesms.PassphraseRequiredActivity import org.thoughtcrime.securesms.R -import org.thoughtcrime.securesms.components.compose.ScreenTitlePane import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity import org.thoughtcrime.securesms.conversation.NewConversationUiState.UserMessage import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupActivity -import org.thoughtcrime.securesms.recipients.PhoneNumber import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.recipients.ui.RecipientPickerScaffold +import org.thoughtcrime.securesms.recipients.ui.RecipientSelection import org.thoughtcrime.securesms.recipients.ui.findby.FindByActivity import org.thoughtcrime.securesms.recipients.ui.findby.FindByMode import org.thoughtcrime.securesms.util.CommunicationActions -import org.thoughtcrime.securesms.window.AppScaffold -import org.thoughtcrime.securesms.window.detailPaneMaxContentWidth -import org.thoughtcrime.securesms.window.isSplitPane -import org.thoughtcrime.securesms.window.rememberAppScaffoldNavigator /** * Allows the user to start a new conversation by selecting a recipient. @@ -121,7 +108,7 @@ private fun NewConversationScreen( contract = FindByActivity.Contract(), onResult = { recipientId -> if (recipientId != null) { - viewModel.openConversation(recipientId) + viewModel.openConversation(selection = RecipientSelection.WithId(recipientId)) } } ) @@ -133,10 +120,9 @@ private fun NewConversationScreen( override fun onCreateNewGroup() = createGroupLauncher.launch(CreateGroupActivity.createIntent(context)) override fun onFindByUsername() = findByLauncher.launch(FindByMode.USERNAME) override fun onFindByPhoneNumber() = findByLauncher.launch(FindByMode.PHONE_NUMBER) - override suspend fun shouldAllowSelection(id: RecipientId?, phone: PhoneNumber?): Boolean = true - override fun onRecipientSelected(id: RecipientId?, phone: PhoneNumber?) = viewModel.openConversation(id, phone) - override fun onPendingRecipientSelectionsConsumed() = Unit - override fun onMessage(id: RecipientId) = viewModel.openConversation(id) + override suspend fun shouldAllowSelection(selection: RecipientSelection): Boolean = true + override fun onRecipientSelected(selection: RecipientSelection) = viewModel.openConversation(selection) + override fun onMessage(id: RecipientId) = viewModel.openConversation(RecipientSelection.WithId(id)) override fun onVoiceCall(recipient: Recipient) = CommunicationActions.startVoiceCall(context, recipient, viewModel::showUserAlreadyInACall) override fun onVideoCall(recipient: Recipient) = CommunicationActions.startVideoCall(context, recipient, viewModel::showUserAlreadyInACall) @@ -196,75 +182,38 @@ private suspend fun openConversation( onComplete() } -@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3AdaptiveApi::class) @Composable private fun NewConversationScreenUi( uiState: NewConversationUiState, callbacks: UiCallbacks ) { - val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass - val isSplitPane = windowSizeClass.isSplitPane(forceSplitPane = uiState.forceSplitPaneOnCompactLandscape) val snackbarHostState = remember { SnackbarHostState() } - AppScaffold( - topBarContent = { - Scaffolds.DefaultTopAppBar( - title = if (!isSplitPane) stringResource(R.string.NewConversationActivity__new_message) else "", - titleContent = { _, title -> Text(text = title, style = MaterialTheme.typography.titleLarge) }, - navigationIcon = ImageVector.vectorResource(R.drawable.symbol_arrow_start_24), - navigationContentDescription = stringResource(R.string.DefaultTopAppBar__navigate_up_content_description), - onNavigationClick = callbacks::onBackPressed, - actions = { TopAppBarActions(callbacks) } - ) - }, - - secondaryContent = { - if (isSplitPane) { - ScreenTitlePane( - title = stringResource(R.string.NewConversationActivity__new_message), - modifier = Modifier.fillMaxSize() - ) - } else { - NewConversationRecipientPicker( - uiState = uiState, - callbacks = callbacks - ) - } - }, - + RecipientPickerScaffold( + title = stringResource(R.string.NewConversationActivity__new_message), + forceSplitPane = uiState.forceSplitPaneOnCompactLandscape, + onNavigateUpClick = callbacks::onBackPressed, + topAppBarActions = { TopAppBarActions(callbacks) }, + snackbarHostState = snackbarHostState, primaryContent = { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier.fillMaxSize() - ) { - NewConversationRecipientPicker( - uiState = uiState, - callbacks = callbacks, - modifier = Modifier.widthIn(max = windowSizeClass.detailPaneMaxContentWidth) - ) + NewConversationRecipientPicker( + uiState = uiState, + callbacks = callbacks + ) + + UserMessagesHost( + userMessage = uiState.userMessage, + onDismiss = callbacks::onUserMessageDismissed, + onRemoveConfirmed = callbacks::onRemoveConfirmed, + onBlockConfirmed = callbacks::onBlockConfirmed, + snackbarHostState = snackbarHostState + ) + + if (uiState.isLookingUpRecipient) { + Dialogs.IndeterminateProgressDialog() } - }, - - snackbarHost = { - SnackbarHost(snackbarHostState) - }, - - navigator = rememberAppScaffoldNavigator( - isSplitPane = isSplitPane - ) + } ) - - UserMessagesHost( - userMessage = uiState.userMessage, - onDismiss = callbacks::onUserMessageDismissed, - onRemoveConfirmed = callbacks::onRemoveConfirmed, - onBlockConfirmed = callbacks::onBlockConfirmed, - snackbarHostState = snackbarHostState - ) - - if (uiState.isLookingUpRecipient) { - Dialogs.IndeterminateProgressDialog() - } } @Composable @@ -323,6 +272,7 @@ private interface UiCallbacks : fun onRemoveConfirmed(recipient: Recipient) fun onBlockConfirmed(recipient: Recipient) fun onUserMessageDismissed(userMessage: UserMessage) + override fun onPendingRecipientSelectionsConsumed() = Unit fun onBackPressed() object Empty : UiCallbacks { @@ -330,8 +280,8 @@ private interface UiCallbacks : override fun onCreateNewGroup() = Unit override fun onFindByUsername() = Unit override fun onFindByPhoneNumber() = Unit - override suspend fun shouldAllowSelection(id: RecipientId?, phone: PhoneNumber?): Boolean = true - override fun onRecipientSelected(id: RecipientId?, phone: PhoneNumber?) = Unit + override suspend fun shouldAllowSelection(selection: RecipientSelection): Boolean = true + override fun onRecipientSelected(selection: RecipientSelection) = Unit override fun onPendingRecipientSelectionsConsumed() = Unit override fun onMessage(id: RecipientId) = Unit override fun onVoiceCall(recipient: Recipient) = Unit @@ -404,7 +354,7 @@ private fun UserMessagesHost( ) is UserMessage.Info.RecipientNotSignalUser -> Dialogs.SimpleMessageDialog( - message = stringResource(R.string.NewConversationActivity__s_is_not_a_signal_user, userMessage.phone!!.displayText), + message = stringResource(R.string.NewConversationActivity__s_is_not_a_signal_user, userMessage.phone.displayText), dismiss = stringResource(android.R.string.ok), onDismiss = { onDismiss(userMessage) } ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/NewConversationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/NewConversationViewModel.kt index ecc2106159..f5ed718600 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/NewConversationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/NewConversationViewModel.kt @@ -26,6 +26,7 @@ import org.thoughtcrime.securesms.recipients.PhoneNumber import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.recipients.RecipientRepository +import org.thoughtcrime.securesms.recipients.ui.RecipientSelection class NewConversationViewModel : ViewModel() { companion object { @@ -41,20 +42,22 @@ class NewConversationViewModel : ViewModel() { internalUiState.update { it.copy(searchQuery = query) } } - fun openConversation(recipientId: RecipientId) { + private fun openConversation(recipientId: RecipientId) { internalUiState.update { it.copy(pendingDestination = recipientId) } } - fun openConversation(id: RecipientId?, phone: PhoneNumber?) { - when { - id != null -> openConversation(recipientId = id) - - SignalStore.account.isRegistered -> { - Log.d(TAG, "[openConversation] Missing recipientId: attempting to look up.") - resolveAndOpenConversation(phone!!) + fun openConversation(selection: RecipientSelection) { + when (selection) { + is RecipientSelection.WithId -> openConversation(recipientId = selection.id) + is RecipientSelection.WithIdAndPhone -> openConversation(recipientId = selection.id) + is RecipientSelection.WithPhone -> { + if (SignalStore.account.isRegistered) { + Log.d(TAG, "[openConversation] Missing recipientId: attempting to look up.") + resolveAndOpenConversation(selection.phone) + } else { + Log.w(TAG, "[openConversation] Cannot look up recipient: account not registered.") + } } - - else -> Log.w(TAG, "[openConversation] Cannot look up recipient: account not registered.") } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/RecipientPicker.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/RecipientPicker.kt index 22366a3b4f..707f4ad413 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/RecipientPicker.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/RecipientPicker.kt @@ -55,6 +55,7 @@ import org.thoughtcrime.securesms.groups.SelectionLimits import org.thoughtcrime.securesms.recipients.PhoneNumber import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.recipients.ui.RecipientSelection import java.util.Optional import java.util.function.Consumer @@ -244,13 +245,20 @@ private fun ContactSelectionListFragment.setUpCallbacks( chatType: Optional, resultConsumer: Consumer ) { - val recipientId = recipientId.orNull() + val id = recipientId.orNull() val phone = number?.let(::PhoneNumber) + val selection = when { + id != null && phone != null -> RecipientSelection.WithIdAndPhone(id, phone) + id != null -> RecipientSelection.WithId(id) + phone != null -> RecipientSelection.WithPhone(phone) + else -> error("Either RecipientId or PhoneNumber must be non-null") + } + coroutineScope.launch { - val shouldAllowSelection = callbacks.listActions.shouldAllowSelection(recipientId, phone) + val shouldAllowSelection = callbacks.listActions.shouldAllowSelection(selection) if (shouldAllowSelection) { - callbacks.listActions.onRecipientSelected(recipientId, phone) + callbacks.listActions.onRecipientSelected(selection) } resultConsumer.accept(shouldAllowSelection) } @@ -378,16 +386,16 @@ data class RecipientPickerCallbacks( * This is called before [onRecipientSelected] to provide a chance to prevent the selection. */ fun onSearchQueryChanged(query: String) - suspend fun shouldAllowSelection(id: RecipientId?, phone: PhoneNumber?): Boolean - fun onRecipientSelected(id: RecipientId?, phone: PhoneNumber?) + suspend fun shouldAllowSelection(selection: RecipientSelection): Boolean + fun onRecipientSelected(selection: RecipientSelection) fun onSelectionChanged(newSelections: List, totalMembersCount: Int) = Unit fun onPendingRecipientSelectionsConsumed() fun onContactsListReset() = Unit object Empty : ListActions { override fun onSearchQueryChanged(query: String) = Unit - override suspend fun shouldAllowSelection(id: RecipientId?, phone: PhoneNumber?): Boolean = true - override fun onRecipientSelected(id: RecipientId?, phone: PhoneNumber?) = Unit + override suspend fun shouldAllowSelection(selection: RecipientSelection): Boolean = true + override fun onRecipientSelected(selection: RecipientSelection) = Unit override fun onPendingRecipientSelectionsConsumed() = Unit override fun onContactsListReset() = Unit } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/CreateGroupActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/CreateGroupActivity.kt index 39a4859a90..f967002892 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/CreateGroupActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/CreateGroupActivity.kt @@ -24,16 +24,12 @@ import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.widthIn import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi -import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -52,11 +48,9 @@ import org.signal.core.ui.compose.AllDevicePreviews import org.signal.core.ui.compose.Buttons import org.signal.core.ui.compose.Dialogs import org.signal.core.ui.compose.Previews -import org.signal.core.ui.compose.Scaffolds import org.signal.core.ui.compose.theme.SignalTheme import org.thoughtcrime.securesms.PassphraseRequiredActivity import org.thoughtcrime.securesms.R -import org.thoughtcrime.securesms.components.compose.ScreenTitlePane import org.thoughtcrime.securesms.contacts.SelectedContact import org.thoughtcrime.securesms.conversation.RecipientPicker import org.thoughtcrime.securesms.conversation.RecipientPickerCallbacks @@ -64,14 +58,10 @@ import org.thoughtcrime.securesms.groups.SelectionLimits import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupUiState.NavTarget import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupUiState.UserMessage import org.thoughtcrime.securesms.groups.ui.creategroup.details.AddGroupDetailsActivity -import org.thoughtcrime.securesms.recipients.PhoneNumber -import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.recipients.ui.RecipientPickerScaffold +import org.thoughtcrime.securesms.recipients.ui.RecipientSelection import org.thoughtcrime.securesms.recipients.ui.findby.FindByActivity import org.thoughtcrime.securesms.recipients.ui.findby.FindByMode -import org.thoughtcrime.securesms.window.AppScaffold -import org.thoughtcrime.securesms.window.detailPaneMaxContentWidth -import org.thoughtcrime.securesms.window.isSplitPane -import org.thoughtcrime.securesms.window.rememberAppScaffoldNavigator import java.text.NumberFormat /** @@ -128,7 +118,7 @@ private fun CreateGroupScreen( override fun onSearchQueryChanged(query: String) = viewModel.onSearchQueryChanged(query) override fun onFindByUsername() = findByLauncher.launch(FindByMode.USERNAME) override fun onFindByPhoneNumber() = findByLauncher.launch(FindByMode.PHONE_NUMBER) - override suspend fun shouldAllowSelection(id: RecipientId?, phone: PhoneNumber?): Boolean = viewModel.shouldAllowSelection(id, phone) + override suspend fun shouldAllowSelection(selection: RecipientSelection): Boolean = viewModel.shouldAllowSelection(selection) override fun onSelectionChanged(newSelections: List, totalMembersCount: Int) = viewModel.onSelectionChanged(newSelections, totalMembersCount) override fun onPendingRecipientSelectionsConsumed() = viewModel.clearPendingRecipientSelections() override fun onNextClicked(): Unit = viewModel.continueToGroupDetails() @@ -164,11 +154,7 @@ private fun CreateGroupScreenUi( uiState: CreateGroupUiState, callbacks: UiCallbacks ) { - val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass - val isSplitPane = windowSizeClass.isSplitPane(forceSplitPane = uiState.forceSplitPane) - val snackbarHostState = remember { SnackbarHostState() } - - val titleText = if (uiState.newSelections.isNotEmpty()) { + val title = if (uiState.newSelections.isNotEmpty()) { pluralStringResource( id = R.plurals.CreateGroupActivity__s_members, count = uiState.totalMembersCount, @@ -178,61 +164,28 @@ private fun CreateGroupScreenUi( stringResource(R.string.CreateGroupActivity__select_members) } - AppScaffold( - topBarContent = { - Scaffolds.DefaultTopAppBar( - title = if (!isSplitPane) titleText else "", - titleContent = { _, title -> Text(text = title, style = MaterialTheme.typography.titleLarge) }, - navigationIcon = ImageVector.vectorResource(R.drawable.symbol_arrow_start_24), - navigationContentDescription = stringResource(R.string.DefaultTopAppBar__navigate_up_content_description), - onNavigationClick = callbacks::onBackPressed - ) - }, - - secondaryContent = { - if (isSplitPane) { - ScreenTitlePane( - title = titleText, - modifier = Modifier.fillMaxSize() - ) - } else { - CreateGroupRecipientPicker( - uiState = uiState, - callbacks = callbacks - ) - } - }, - + RecipientPickerScaffold( + title = title, + forceSplitPane = uiState.forceSplitPane, + onNavigateUpClick = callbacks::onBackPressed, + topAppBarActions = {}, + snackbarHostState = remember { SnackbarHostState() }, primaryContent = { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier.fillMaxSize() - ) { - CreateGroupRecipientPicker( - uiState = uiState, - callbacks = callbacks, - modifier = Modifier.widthIn(max = windowSizeClass.detailPaneMaxContentWidth) - ) + CreateGroupRecipientPicker( + uiState = uiState, + callbacks = callbacks + ) + + UserMessagesHost( + userMessage = uiState.userMessage, + onDismiss = callbacks::onUserMessageDismissed + ) + + if (uiState.isLookingUpRecipient) { + Dialogs.IndeterminateProgressDialog() } - }, - - snackbarHost = { - SnackbarHost(snackbarHostState) - }, - - navigator = rememberAppScaffoldNavigator( - isSplitPane = isSplitPane - ) + } ) - - UserMessagesHost( - userMessage = uiState.userMessage, - onDismiss = callbacks::onUserMessageDismissed - ) - - if (uiState.isLookingUpRecipient) { - Dialogs.IndeterminateProgressDialog() - } } @Composable @@ -296,7 +249,7 @@ private interface UiCallbacks : RecipientPickerCallbacks.FindByUsername, RecipientPickerCallbacks.FindByPhoneNumber { - override fun onRecipientSelected(id: RecipientId?, phone: PhoneNumber?) = Unit + override fun onRecipientSelected(selection: RecipientSelection) = Unit fun onNextClicked() fun onUserMessageDismissed(userMessage: UserMessage) fun onBackPressed() @@ -306,7 +259,7 @@ private interface UiCallbacks : override fun onSearchQueryChanged(query: String) = Unit override fun onFindByUsername() = Unit override fun onFindByPhoneNumber() = Unit - override suspend fun shouldAllowSelection(id: RecipientId?, phone: PhoneNumber?): Boolean = true + override suspend fun shouldAllowSelection(selection: RecipientSelection): Boolean = true override fun onPendingRecipientSelectionsConsumed() = Unit override fun onNextClicked() = Unit override fun onUserMessageDismissed(userMessage: UserMessage) = Unit diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/CreateGroupViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/CreateGroupViewModel.kt index 8786f1a0a3..f6a3087878 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/CreateGroupViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/CreateGroupViewModel.kt @@ -27,6 +27,7 @@ import org.thoughtcrime.securesms.recipients.PhoneNumber import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.recipients.RecipientRepository +import org.thoughtcrime.securesms.recipients.ui.RecipientSelection import org.thoughtcrime.securesms.util.RemoteConfig import java.io.IOException import kotlin.time.Duration.Companion.seconds @@ -43,8 +44,9 @@ class CreateGroupViewModel : ViewModel() { internalUiState.update { it.copy(searchQuery = query) } } - suspend fun shouldAllowSelection(id: RecipientId?, phone: PhoneNumber?): Boolean { - return if (id != null) true else recipientExists(phone!!) + suspend fun shouldAllowSelection(selection: RecipientSelection): Boolean = when (selection) { + is RecipientSelection.WithId, is RecipientSelection.WithIdAndPhone -> true + is RecipientSelection.WithPhone -> recipientExists(selection.phone) } private suspend fun recipientExists(phone: PhoneNumber): Boolean { diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/RecipientPickerScaffold.kt b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/RecipientPickerScaffold.kt new file mode 100644 index 0000000000..1a310159fd --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/RecipientPickerScaffold.kt @@ -0,0 +1,118 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.recipients.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import org.signal.core.ui.compose.AllDevicePreviews +import org.signal.core.ui.compose.Previews +import org.signal.core.ui.compose.Scaffolds +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.compose.ScreenTitlePane +import org.thoughtcrime.securesms.window.AppScaffold +import org.thoughtcrime.securesms.window.detailPaneMaxContentWidth +import org.thoughtcrime.securesms.window.isSplitPane +import org.thoughtcrime.securesms.window.rememberAppScaffoldNavigator + +/** + * Provides the common adaptive layout structure for recipient picker screens. + */ +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3AdaptiveApi::class) +@Composable +fun RecipientPickerScaffold( + title: String, + forceSplitPane: Boolean, + onNavigateUpClick: () -> Unit, + topAppBarActions: @Composable () -> Unit, + snackbarHostState: SnackbarHostState, + primaryContent: @Composable () -> Unit +) { + val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass + val isSplitPane = windowSizeClass.isSplitPane(forceSplitPane = forceSplitPane) + + AppScaffold( + topBarContent = { + Scaffolds.DefaultTopAppBar( + title = if (!isSplitPane) title else "", + titleContent = { _, titleText -> Text(text = titleText, style = MaterialTheme.typography.titleLarge) }, + navigationIcon = ImageVector.vectorResource(R.drawable.symbol_arrow_start_24), + navigationContentDescription = stringResource(R.string.DefaultTopAppBar__navigate_up_content_description), + onNavigationClick = onNavigateUpClick, + actions = { topAppBarActions() } + ) + }, + + secondaryContent = { + if (isSplitPane) { + ScreenTitlePane( + title = title, + modifier = Modifier.fillMaxSize() + ) + } else { + primaryContent() + } + }, + + primaryContent = { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxSize() + ) { + Box(modifier = Modifier.widthIn(max = windowSizeClass.detailPaneMaxContentWidth)) { + primaryContent() + } + } + }, + + snackbarHost = { + SnackbarHost(snackbarHostState) + }, + + navigator = rememberAppScaffoldNavigator( + isSplitPane = isSplitPane + ) + ) +} + +@AllDevicePreviews +@Composable +private fun RecipientPickerScaffoldPreview() { + Previews.Preview { + RecipientPickerScaffold( + title = "Screen Title", + forceSplitPane = false, + onNavigateUpClick = {}, + topAppBarActions = {}, + snackbarHostState = SnackbarHostState(), + primaryContent = { + Box( + modifier = Modifier + .fillMaxSize() + .background(color = Color.Gray), + contentAlignment = Alignment.Center + ) { + Text(text = "primaryContent") + } + } + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/RecipientSelection.kt b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/RecipientSelection.kt new file mode 100644 index 0000000000..b5659e1ed6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/RecipientSelection.kt @@ -0,0 +1,15 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.recipients.ui + +import org.thoughtcrime.securesms.recipients.PhoneNumber +import org.thoughtcrime.securesms.recipients.RecipientId + +sealed interface RecipientSelection { + data class WithId(val id: RecipientId) : RecipientSelection + data class WithPhone(val phone: PhoneNumber) : RecipientSelection + data class WithIdAndPhone(val id: RecipientId, val phone: PhoneNumber) : RecipientSelection +}