mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-21 09:20:19 +01:00
Add split pane UI for new conversation screen.
This commit is contained in:
committed by
Alex Hart
parent
0f35eb7f7b
commit
534756c833
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user