Add split pane UI for new conversation screen.

This commit is contained in:
jeffrey-signal
2025-10-08 14:47:48 -04:00
committed by Alex Hart
parent 0f35eb7f7b
commit 534756c833
15 changed files with 3975 additions and 38 deletions

View File

@@ -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")

View File

@@ -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"

View File

@@ -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());
}

View File

@@ -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;

View File

@@ -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
)
)
}

View File

@@ -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
)
}
}

View File

@@ -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
)

View File

@@ -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
}
}

View File

@@ -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

View File

@@ -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,

View File

@@ -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
)
}
}
)
}
}

View File

@@ -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>

View File

@@ -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)
}

View File

@@ -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