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

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