mirror of
https://github.com/signalapp/Signal-Android.git
synced 2025-12-24 13:08:46 +00:00
Add split pane UI for new conversation screen.
This commit is contained in:
committed by
Alex Hart
parent
0f35eb7f7b
commit
534756c833
@@ -508,7 +508,6 @@ dependencies {
|
||||
implementation(project(":core-ui"))
|
||||
|
||||
implementation(libs.androidx.fragment.ktx)
|
||||
implementation(libs.androidx.fragment.compose)
|
||||
implementation(libs.androidx.appcompat) {
|
||||
version {
|
||||
strictly("1.6.1")
|
||||
|
||||
@@ -697,6 +697,11 @@
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||
android:exported="false"/>
|
||||
|
||||
<activity
|
||||
android:name=".conversation.NewConversationActivityV2"
|
||||
android:exported="false"
|
||||
android:theme="@style/Signal.DayNight.NoActionBar" />
|
||||
|
||||
<activity android:name=".recipients.ui.findby.FindByActivity"
|
||||
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
|
||||
@@ -138,16 +138,16 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
private TextView headerActionView;
|
||||
private ContactSearchMediator contactSearchMediator;
|
||||
|
||||
@Nullable private NewConversationCallback newConversationCallback;
|
||||
@Nullable private FindByCallback findByCallback;
|
||||
@Nullable private NewCallCallback newCallCallback;
|
||||
@Nullable private ScrollCallback scrollCallback;
|
||||
@Nullable private OnItemLongClickListener onItemLongClickListener;
|
||||
private SelectionLimits selectionLimit = SelectionLimits.NO_LIMITS;
|
||||
private Set<RecipientId> currentSelection;
|
||||
private boolean isMulti;
|
||||
private boolean canSelectSelf;
|
||||
private boolean resetPositionOnCommit = false;
|
||||
@Nullable private NewConversationCallback newConversationCallback;
|
||||
@Nullable private FindByCallback findByCallback;
|
||||
@Nullable private NewCallCallback newCallCallback;
|
||||
@Nullable private ScrollCallback scrollCallback;
|
||||
@Nullable private OnItemLongClickListener onItemLongClickListener;
|
||||
private SelectionLimits selectionLimit = SelectionLimits.NO_LIMITS;
|
||||
private Set<RecipientId> currentSelection;
|
||||
private boolean isMulti;
|
||||
private boolean canSelectSelf;
|
||||
private boolean resetPositionOnCommit = false;
|
||||
|
||||
private ListClickListener listClickListener = new ListClickListener();
|
||||
@Nullable private SwipeRefreshLayout.OnRefreshListener onRefreshListener;
|
||||
@@ -161,7 +161,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
}
|
||||
|
||||
if (context instanceof FindByCallback) {
|
||||
findByCallback = (FindByCallback) context;
|
||||
showFindByUsernameAndPhoneOptions((FindByCallback) context);
|
||||
}
|
||||
|
||||
if (context instanceof NewCallCallback) {
|
||||
@@ -177,11 +177,11 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
}
|
||||
|
||||
if (getParentFragment() instanceof OnContactSelectedListener) {
|
||||
onContactSelectedListener = (OnContactSelectedListener) getParentFragment();
|
||||
setOnContactSelectedListener((OnContactSelectedListener) getParentFragment());
|
||||
}
|
||||
|
||||
if (context instanceof OnContactSelectedListener) {
|
||||
onContactSelectedListener = (OnContactSelectedListener) context;
|
||||
setOnContactSelectedListener((OnContactSelectedListener) context);
|
||||
}
|
||||
|
||||
if (context instanceof OnSelectionLimitReachedListener) {
|
||||
@@ -209,6 +209,14 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
}
|
||||
}
|
||||
|
||||
public void showFindByUsernameAndPhoneOptions(@Nullable FindByCallback callback) {
|
||||
this.findByCallback = callback;
|
||||
}
|
||||
|
||||
public void setOnContactSelectedListener(@Nullable OnContactSelectedListener listener) {
|
||||
this.onContactSelectedListener = listener;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onActivityCreated(Bundle icicle) {
|
||||
super.onActivityCreated(icicle);
|
||||
@@ -221,7 +229,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
super.onStart();
|
||||
|
||||
if (hasContactsPermissions(requireContext()) && !TextSecurePreferences.hasSuccessfullyRetrievedDirectory(getActivity())) {
|
||||
handleContactPermissionGranted();
|
||||
handleContactPermissionGranted();
|
||||
} else {
|
||||
requireActivity().getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN);
|
||||
contactSearchMediator.refresh();
|
||||
@@ -232,13 +240,13 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
|
||||
View view = inflater.inflate(R.layout.contact_selection_list_fragment, container, false);
|
||||
|
||||
emptyText = view.findViewById(android.R.id.empty);
|
||||
recyclerView = view.findViewById(R.id.recycler_view);
|
||||
swipeRefresh = view.findViewById(R.id.swipe_refresh);
|
||||
fastScroller = view.findViewById(R.id.fast_scroller);
|
||||
chipRecycler = view.findViewById(R.id.chipRecycler);
|
||||
constraintLayout = view.findViewById(R.id.container);
|
||||
headerActionView = view.findViewById(R.id.header_action);
|
||||
emptyText = view.findViewById(android.R.id.empty);
|
||||
recyclerView = view.findViewById(R.id.recycler_view);
|
||||
swipeRefresh = view.findViewById(R.id.swipe_refresh);
|
||||
fastScroller = view.findViewById(R.id.fast_scroller);
|
||||
chipRecycler = view.findViewById(R.id.chipRecycler);
|
||||
constraintLayout = view.findViewById(R.id.container);
|
||||
headerActionView = view.findViewById(R.id.header_action);
|
||||
|
||||
final LinearLayoutManager layoutManager = new LinearLayoutManager(requireContext());
|
||||
|
||||
@@ -441,7 +449,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
@Override
|
||||
public void onDestroyView() {
|
||||
super.onDestroyView();
|
||||
constraintLayout = null;
|
||||
constraintLayout = null;
|
||||
onRefreshListener = null;
|
||||
}
|
||||
|
||||
@@ -723,7 +731,7 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
|
||||
SimpleTask.run(getViewLifecycleOwner().getLifecycle(), () -> {
|
||||
return UsernameRepository.fetchAciForUsername(UsernameUtil.sanitizeUsernameFromSearch(username));
|
||||
}, result -> {
|
||||
}, result -> {
|
||||
loadingDialog.dismiss();
|
||||
|
||||
// TODO Could be more specific with errors
|
||||
@@ -756,10 +764,10 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
selectedContact.getNumber(),
|
||||
Optional.empty(),
|
||||
allowed -> {
|
||||
if (allowed) {
|
||||
markContactSelected(selectedContact);
|
||||
}
|
||||
});
|
||||
if (allowed) {
|
||||
markContactSelected(selectedContact);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
markContactSelected(selectedContact);
|
||||
}
|
||||
@@ -913,9 +921,10 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
builder.setQuery(contactSearchState.getQuery());
|
||||
|
||||
if ((newConversationCallback != null || findByCallback != null) &&
|
||||
!hasContactsPermissions(requireContext()) &&
|
||||
!hasContactsPermissions(requireContext()) &&
|
||||
!SignalStore.uiHints().getDismissedContactsPermissionBanner() &&
|
||||
!hasQuery) {
|
||||
!hasQuery)
|
||||
{
|
||||
builder.arbitrary(ContactSelectionListAdapter.ArbitraryRepository.ArbitraryRow.FIND_CONTACTS_BANNER.getCode());
|
||||
}
|
||||
|
||||
|
||||
@@ -25,8 +25,13 @@ import org.thoughtcrime.securesms.util.ServiceUtil;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
|
||||
/**
|
||||
* A search input field for finding recipients.
|
||||
* <p>
|
||||
* In compose, use RecipientSearchField instead.
|
||||
*/
|
||||
public final class ContactFilterView extends FrameLayout {
|
||||
private OnFilterChangedListener listener;
|
||||
private OnFilterChangedListener listener;
|
||||
|
||||
private final EditText searchText;
|
||||
private final AnimatingToggle toggle;
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.compose
|
||||
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.thoughtcrime.securesms.window.WindowSizeClass
|
||||
|
||||
/**
|
||||
* Displays the screen title for split-pane UIs on tablets and foldable devices.
|
||||
*/
|
||||
@Composable
|
||||
fun ScreenTitlePane(
|
||||
title: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val windowSizeClass = WindowSizeClass.rememberWindowSizeClass()
|
||||
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.headlineLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = modifier
|
||||
.padding(
|
||||
start = if (windowSizeClass.isExtended()) 80.dp else 20.dp,
|
||||
end = 20.dp,
|
||||
top = 12.dp,
|
||||
bottom = 12.dp
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.conversation
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.compose.setContent
|
||||
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.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import org.signal.core.ui.compose.AllDevicePreviews
|
||||
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.util.viewModel
|
||||
import org.thoughtcrime.securesms.window.AppScaffoldWithTopBar
|
||||
import org.thoughtcrime.securesms.window.WindowSizeClass
|
||||
import org.thoughtcrime.securesms.window.rememberAppScaffoldNavigator
|
||||
|
||||
/**
|
||||
* Allows the user to start a new conversation by selecting a recipient.
|
||||
*
|
||||
* A modernized compose-based replacement for [org.thoughtcrime.securesms.NewConversationActivity].
|
||||
*/
|
||||
class NewConversationActivityV2 : PassphraseRequiredActivity() {
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun createIntent(context: Context): Intent = Intent(context, NewConversationActivityV2::class.java)
|
||||
}
|
||||
|
||||
private val viewModel by viewModel { NewConversationViewModel() }
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
|
||||
super.onCreate(savedInstanceState, ready)
|
||||
|
||||
setContent {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
SignalTheme {
|
||||
NewConversationScreen(
|
||||
uiState = uiState,
|
||||
callbacks = object : Callbacks {
|
||||
override fun onBackPressed() = onBackPressedDispatcher.onBackPressed()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3AdaptiveApi::class)
|
||||
@Composable
|
||||
private fun NewConversationScreen(
|
||||
uiState: NewConversationUiState,
|
||||
callbacks: Callbacks
|
||||
) {
|
||||
val windowSizeClass = WindowSizeClass.rememberWindowSizeClass()
|
||||
val isSplitPane = windowSizeClass.isSplitPane(forceSplitPaneOnCompactLandscape = uiState.forceSplitPaneOnCompactLandscape)
|
||||
|
||||
AppScaffoldWithTopBar(
|
||||
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
|
||||
)
|
||||
},
|
||||
listContent = {
|
||||
if (isSplitPane) {
|
||||
ScreenTitlePane(
|
||||
title = stringResource(R.string.NewConversationActivity__new_message),
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
} else {
|
||||
DetailPaneContent()
|
||||
}
|
||||
},
|
||||
|
||||
detailContent = {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
DetailPaneContent(
|
||||
modifier = Modifier
|
||||
.widthIn(max = windowSizeClass.detailPaneMaxContentWidth)
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
navigator = rememberAppScaffoldNavigator(
|
||||
isSplitPane = isSplitPane
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private interface Callbacks {
|
||||
fun onBackPressed()
|
||||
|
||||
object Empty : Callbacks {
|
||||
override fun onBackPressed() = Unit
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DetailPaneContent(
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
RecipientPicker(
|
||||
showFindByUsernameAndPhoneOptions = true,
|
||||
callbacks = RecipientPickerCallbacks.Empty, // TODO(jeffrey) implement callbacks
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(vertical = 12.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@AllDevicePreviews
|
||||
@Composable
|
||||
private fun NewConversationScreenPreview() {
|
||||
Previews.Preview {
|
||||
NewConversationScreen(
|
||||
uiState = NewConversationUiState(
|
||||
forceSplitPaneOnCompactLandscape = false
|
||||
),
|
||||
callbacks = Callbacks.Empty
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.conversation
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
|
||||
class NewConversationViewModel : ViewModel() {
|
||||
private val _uiState = MutableStateFlow(NewConversationUiState())
|
||||
val uiState: StateFlow<NewConversationUiState> = _uiState.asStateFlow()
|
||||
}
|
||||
|
||||
data class NewConversationUiState(
|
||||
val forceSplitPaneOnCompactLandscape: Boolean = SignalStore.internal.forceSplitPaneOnCompactLandscape
|
||||
)
|
||||
@@ -0,0 +1,155 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.conversation
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.fragment.compose.rememberFragmentState
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Fragments
|
||||
import org.thoughtcrime.securesms.ContactSelectionListFragment
|
||||
import org.thoughtcrime.securesms.components.ContactFilterView
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
|
||||
/**
|
||||
* Provides a recipient search and selection UI.
|
||||
*/
|
||||
@Composable
|
||||
fun RecipientPicker(
|
||||
showFindByUsernameAndPhoneOptions: Boolean,
|
||||
callbacks: RecipientPickerCallbacks,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var searchQuery by rememberSaveable { mutableStateOf("") }
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
) {
|
||||
RecipientSearchField(
|
||||
onFilterChanged = { filter ->
|
||||
searchQuery = filter
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
)
|
||||
|
||||
RecipientSearchResultsList(
|
||||
searchQuery = searchQuery,
|
||||
showFindByUsernameAndPhoneOptions = showFindByUsernameAndPhoneOptions,
|
||||
callbacks = callbacks,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(top = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A search input field for finding recipients.
|
||||
*
|
||||
* Intended to be a compose-based replacement for [ContactFilterView].
|
||||
*/
|
||||
@Composable
|
||||
private fun RecipientSearchField(
|
||||
onFilterChanged: (String) -> Unit,
|
||||
@StringRes hintText: Int? = null,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val wrappedView = remember {
|
||||
ContactFilterView(context, null, 0).apply {
|
||||
hintText?.let { setHint(it) }
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(onFilterChanged) {
|
||||
wrappedView.setOnFilterChangedListener { filter -> onFilterChanged(filter) }
|
||||
onDispose {
|
||||
wrappedView.setOnFilterChangedListener(null)
|
||||
}
|
||||
}
|
||||
|
||||
AndroidView(
|
||||
factory = { wrappedView },
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RecipientSearchResultsList(
|
||||
searchQuery: String,
|
||||
showFindByUsernameAndPhoneOptions: Boolean,
|
||||
callbacks: RecipientPickerCallbacks,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val fragmentState = rememberFragmentState()
|
||||
var currentFragment by remember { mutableStateOf<ContactSelectionListFragment?>(null) }
|
||||
|
||||
Fragments.Fragment<ContactSelectionListFragment>(
|
||||
fragmentState = fragmentState,
|
||||
onUpdate = { fragment ->
|
||||
currentFragment = fragment
|
||||
currentFragment?.view?.setPadding(0, 0, 0, 0)
|
||||
|
||||
if (showFindByUsernameAndPhoneOptions) {
|
||||
fragment.showFindByUsernameAndPhoneOptions(object : ContactSelectionListFragment.FindByCallback {
|
||||
override fun onFindByUsername() = callbacks.onFindByUsernameClicked()
|
||||
override fun onFindByPhoneNumber() = callbacks.onFindByPhoneNumberClicked()
|
||||
})
|
||||
}
|
||||
},
|
||||
modifier = modifier
|
||||
)
|
||||
|
||||
var previousQueryText by rememberSaveable { mutableStateOf("") }
|
||||
LaunchedEffect(searchQuery) {
|
||||
if (previousQueryText != searchQuery) {
|
||||
if (searchQuery.isNotBlank()) {
|
||||
currentFragment?.setQueryFilter(searchQuery)
|
||||
} else {
|
||||
currentFragment?.resetQueryFilter()
|
||||
}
|
||||
previousQueryText = searchQuery
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun RecipientPickerPreview() {
|
||||
RecipientPicker(
|
||||
showFindByUsernameAndPhoneOptions = true,
|
||||
callbacks = RecipientPickerCallbacks.Empty
|
||||
)
|
||||
}
|
||||
|
||||
interface RecipientPickerCallbacks {
|
||||
fun onFindByUsernameClicked()
|
||||
fun onFindByPhoneNumberClicked()
|
||||
fun onRecipientClicked(id: RecipientId)
|
||||
|
||||
object Empty : RecipientPickerCallbacks {
|
||||
override fun onFindByUsernameClicked() = Unit
|
||||
override fun onFindByPhoneNumberClicked() = Unit
|
||||
override fun onRecipientClicked(id: RecipientId) = Unit
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,7 @@ import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.window.core.ExperimentalWindowCoreApi
|
||||
@@ -86,6 +87,12 @@ enum class WindowSizeClass(
|
||||
EXTENDED_PORTRAIT(Navigation.RAIL),
|
||||
EXTENDED_LANDSCAPE(Navigation.RAIL);
|
||||
|
||||
val listPaneDefaultPreferredWidth: Dp
|
||||
get() = if (isExtended()) 416.dp else 316.dp
|
||||
|
||||
val detailPaneMaxContentWidth: Dp = 624.dp
|
||||
val horizontalPartitionDefaultSpacerSize: Dp = 12.dp
|
||||
|
||||
fun isCompact(): Boolean = this == COMPACT_PORTRAIT || this == COMPACT_LANDSCAPE
|
||||
fun isMedium(): Boolean = this == MEDIUM_PORTRAIT || this == MEDIUM_LANDSCAPE
|
||||
fun isExtended(): Boolean = this == EXTENDED_PORTRAIT || this == EXTENDED_LANDSCAPE
|
||||
@@ -93,8 +100,11 @@ enum class WindowSizeClass(
|
||||
fun isLandscape(): Boolean = this == COMPACT_LANDSCAPE || this == MEDIUM_LANDSCAPE || this == EXTENDED_LANDSCAPE
|
||||
fun isPortrait(): Boolean = !isLandscape()
|
||||
|
||||
fun isSplitPane(): Boolean {
|
||||
return if (isLargeScreenSupportEnabled() && SignalStore.internal.forceSplitPaneOnCompactLandscape) {
|
||||
@JvmOverloads
|
||||
fun isSplitPane(
|
||||
forceSplitPaneOnCompactLandscape: Boolean = SignalStore.internal.forceSplitPaneOnCompactLandscape
|
||||
): Boolean {
|
||||
return if (isLargeScreenSupportEnabled() && forceSplitPaneOnCompactLandscape) {
|
||||
this != COMPACT_PORTRAIT
|
||||
} else {
|
||||
this.navigation != Navigation.BAR
|
||||
|
||||
@@ -19,7 +19,9 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
|
||||
/**
|
||||
* AppScaffoldNavigator wraps a delegate navigator (such as the value returned by [rememberThreePaneScaffoldNavigatorDelegate]
|
||||
@@ -85,9 +87,12 @@ open class AppScaffoldNavigator<T> @RememberInComposition constructor(private va
|
||||
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
|
||||
@Composable
|
||||
fun rememberAppScaffoldNavigator(
|
||||
isSplitPane: Boolean,
|
||||
horizontalPartitionSpacerSize: Dp,
|
||||
defaultPanePreferredWidth: Dp
|
||||
windowSizeClass: WindowSizeClass = WindowSizeClass.rememberWindowSizeClass(),
|
||||
isSplitPane: Boolean = windowSizeClass.isSplitPane(
|
||||
forceSplitPaneOnCompactLandscape = if (LocalInspectionMode.current) false else SignalStore.internal.forceSplitPaneOnCompactLandscape
|
||||
),
|
||||
horizontalPartitionSpacerSize: Dp = windowSizeClass.horizontalPartitionDefaultSpacerSize,
|
||||
defaultPanePreferredWidth: Dp = windowSizeClass.listPaneDefaultPreferredWidth
|
||||
): AppScaffoldNavigator<Any> {
|
||||
val delegate = rememberThreePaneScaffoldNavigatorDelegate(
|
||||
isSplitPane,
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.window
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
|
||||
import androidx.compose.material3.adaptive.layout.PaneExpansionState
|
||||
import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldScope
|
||||
import androidx.compose.material3.adaptive.layout.rememberPaneExpansionState
|
||||
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.platform.LocalInspectionMode
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
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.keyvalue.SignalStore
|
||||
|
||||
/**
|
||||
* Wraps [AppScaffold], adding a top app bar that spans across both the list and detail panes.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
|
||||
@Composable
|
||||
fun AppScaffoldWithTopBar(
|
||||
navigator: AppScaffoldNavigator<Any> = rememberAppScaffoldNavigator(),
|
||||
topBarContent: @Composable () -> Unit = {},
|
||||
detailContent: @Composable () -> Unit = {},
|
||||
navRailContent: @Composable () -> Unit = {},
|
||||
bottomNavContent: @Composable () -> Unit = {},
|
||||
paneExpansionState: PaneExpansionState = rememberPaneExpansionState(),
|
||||
paneExpansionDragHandle: (@Composable ThreePaneScaffoldScope.(PaneExpansionState) -> Unit)? = null,
|
||||
animatorFactory: AppScaffoldAnimationStateFactory = AppScaffoldAnimationStateFactory.Default,
|
||||
listContent: @Composable () -> Unit
|
||||
) {
|
||||
val windowSizeClass = WindowSizeClass.rememberWindowSizeClass()
|
||||
val isSplitPane = windowSizeClass.isSplitPane(
|
||||
forceSplitPaneOnCompactLandscape = if (LocalInspectionMode.current) false else SignalStore.internal.forceSplitPaneOnCompactLandscape
|
||||
)
|
||||
|
||||
if (isSplitPane) {
|
||||
Column {
|
||||
topBarContent()
|
||||
|
||||
AppScaffold(
|
||||
navigator = navigator,
|
||||
detailContent = detailContent,
|
||||
navRailContent = navRailContent,
|
||||
bottomNavContent = bottomNavContent,
|
||||
paneExpansionState = paneExpansionState,
|
||||
paneExpansionDragHandle = paneExpansionDragHandle,
|
||||
animatorFactory = animatorFactory,
|
||||
listContent = listContent
|
||||
)
|
||||
}
|
||||
} else {
|
||||
AppScaffold(
|
||||
navigator = navigator,
|
||||
detailContent = detailContent,
|
||||
navRailContent = navRailContent,
|
||||
bottomNavContent = bottomNavContent,
|
||||
paneExpansionState = paneExpansionState,
|
||||
paneExpansionDragHandle = paneExpansionDragHandle,
|
||||
animatorFactory = animatorFactory,
|
||||
listContent = {
|
||||
Scaffold(topBar = topBarContent) { paddingValues ->
|
||||
Box(modifier = Modifier.padding(paddingValues)) {
|
||||
listContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3AdaptiveApi::class)
|
||||
@AllDevicePreviews
|
||||
@Composable
|
||||
private fun AppScaffoldWithTopBarPreview() {
|
||||
Previews.Preview {
|
||||
val windowSizeClass = WindowSizeClass.rememberWindowSizeClass()
|
||||
val isSplitPane = windowSizeClass.isSplitPane(forceSplitPaneOnCompactLandscape = false)
|
||||
|
||||
AppScaffoldWithTopBar(
|
||||
navigator = rememberAppScaffoldNavigator(),
|
||||
|
||||
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 = "",
|
||||
onNavigationClick = { }
|
||||
)
|
||||
},
|
||||
|
||||
listContent = {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(color = Color.Red)
|
||||
) {
|
||||
Text(
|
||||
text = "ListContent\n$windowSizeClass",
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
detailContent = {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(color = Color.Blue)
|
||||
) {
|
||||
Text(
|
||||
text = "DetailContent",
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -5926,8 +5926,8 @@
|
||||
<item quantity="other">%1$s are not Signal users</item>
|
||||
</plurals>
|
||||
|
||||
<!-- ContactFilterView -->
|
||||
<string name="ContactFilterView__search_name_or_number">Search name or number</string>
|
||||
<!-- Hint text that shows in the recipient search box when no text is entered yet. -->
|
||||
<string name="ContactFilterView__search_name_or_number">Name, username or number</string>
|
||||
|
||||
<!-- VoiceNotePlayerView -->
|
||||
<string name="VoiceNotePlayerView__dot_s">· %1$s</string>
|
||||
|
||||
@@ -29,4 +29,5 @@ dependencies {
|
||||
api(libs.androidx.compose.material3.adaptive.navigation)
|
||||
api(libs.androidx.compose.ui.tooling.preview)
|
||||
debugApi(libs.androidx.compose.ui.tooling.core)
|
||||
api(libs.androidx.fragment.compose)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.core.ui.compose
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.wrapContentSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
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.platform.LocalInspectionMode
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.compose.AndroidFragment
|
||||
import androidx.fragment.compose.FragmentState
|
||||
import androidx.fragment.compose.rememberFragmentState
|
||||
import org.signal.core.ui.compose.Fragments.Fragment
|
||||
|
||||
object Fragments {
|
||||
/**
|
||||
* Wraps an [Fragment], displaying the fragment at runtime or a placeholder in compose previews to avoid rendering errors that occur when
|
||||
* using [Fragment] in @Preview composables.
|
||||
*/
|
||||
@Composable
|
||||
inline fun <reified T : Fragment> Fragment(
|
||||
modifier: Modifier = Modifier,
|
||||
fragmentState: FragmentState = rememberFragmentState(),
|
||||
arguments: Bundle = Bundle.EMPTY,
|
||||
noinline onUpdate: (T) -> Unit = { }
|
||||
) {
|
||||
if (!LocalInspectionMode.current) {
|
||||
AndroidFragment(clazz = T::class.java, modifier, fragmentState, arguments, onUpdate)
|
||||
} else {
|
||||
Text(
|
||||
text = "[${T::class.simpleName}]",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Gray)
|
||||
.wrapContentSize(Alignment.Center)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps an [Fragment], displaying the fragment at runtime or a placeholder in compose previews to avoid rendering errors that occur when
|
||||
* using [Fragment] in @Preview composables.
|
||||
*/
|
||||
@Composable
|
||||
fun <T : Fragment> Fragment(
|
||||
clazz: Class<T>,
|
||||
modifier: Modifier = Modifier,
|
||||
fragmentState: FragmentState = rememberFragmentState(),
|
||||
arguments: Bundle = Bundle.EMPTY,
|
||||
onUpdate: (T) -> Unit = { }
|
||||
) {
|
||||
if (!LocalInspectionMode.current) {
|
||||
AndroidFragment(clazz = clazz, modifier, fragmentState, arguments, onUpdate)
|
||||
} else {
|
||||
Text(
|
||||
text = "[${clazz.simpleName}]",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Gray)
|
||||
.wrapContentSize(Alignment.Center)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user