Convert ContactFilterView to compose.

This commit is contained in:
jeffrey-signal
2025-10-27 17:58:19 -04:00
committed by GitHub
parent c316381159
commit 963a72a660
7 changed files with 262 additions and 69 deletions

View File

@@ -27,9 +27,10 @@ import org.thoughtcrime.securesms.util.ViewUtil;
/**
* A search input field for finding recipients.
* <p>
* In compose, use RecipientSearchField instead.
*
* @deprecated Use the RecipientSearchBar composable instead.
*/
@Deprecated
public final class ContactFilterView extends FrameLayout {
private OnFilterChangedListener listener;

View File

@@ -0,0 +1,46 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.compose
import android.os.Build
import android.view.inputmethod.EditorInfo
import androidx.compose.runtime.Composable
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.platform.InterceptPlatformTextInput
import androidx.compose.ui.platform.PlatformTextInputMethodRequest
/**
* When [enabled]=true, this function sets the [EditorInfo.IME_FLAG_NO_PERSONALIZED_LEARNING] flag for all text fields within its content to enable the
* incognito keyboard.
*
* This workaround is needed until it's possible to configure granular IME options for a [TextField].
* https://issuetracker.google.com/issues/359257538
*/
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun ProvideIncognitoKeyboard(
enabled: Boolean,
content: @Composable () -> Unit
) {
if (enabled) {
InterceptPlatformTextInput(
interceptor = { request, nextHandler ->
val modifiedRequest = PlatformTextInputMethodRequest { outAttributes ->
request.createInputConnection(outAttributes).also {
if (Build.VERSION.SDK_INT >= 26) {
outAttributes.imeOptions = outAttributes.imeOptions or EditorInfo.IME_FLAG_NO_PERSONALIZED_LEARNING
}
}
}
nextHandler.startInputMethod(modifiedRequest)
}
) {
content()
}
} else {
content()
}
}

View File

@@ -7,13 +7,14 @@ package org.thoughtcrime.securesms.conversation
import android.view.View
import android.view.ViewGroup
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.isImeVisible
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
@@ -22,12 +23,13 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.fragment.compose.rememberFragmentState
import androidx.recyclerview.widget.RecyclerView
import kotlinx.coroutines.CoroutineScope
@@ -40,7 +42,6 @@ import org.signal.core.util.DimensionUnit
import org.signal.core.util.orNull
import org.thoughtcrime.securesms.ContactSelectionListFragment
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.ContactFilterView
import org.thoughtcrime.securesms.components.menu.ActionItem
import org.thoughtcrime.securesms.components.menu.SignalContextMenu
import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode
@@ -54,13 +55,14 @@ 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.util.ViewUtil
import java.util.Optional
import java.util.function.Consumer
/**
* Provides a recipient search and selection UI.
*/
@Suppress("KotlinConstantConditions")
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun RecipientPicker(
searchQuery: String,
@@ -78,11 +80,26 @@ fun RecipientPicker(
Column(
modifier = modifier
) {
RecipientSearchField(
searchQuery = searchQuery,
onFilterChanged = { filter -> callbacks.listActions.onSearchQueryChanged(query = filter) },
focusAndShowKeyboard = focusAndShowKeyboard,
val focusRequester = remember { FocusRequester() }
var shouldRequestFocus by rememberSaveable { mutableStateOf(focusAndShowKeyboard) }
LaunchedEffect(Unit) {
if (shouldRequestFocus) {
focusRequester.requestFocus()
}
}
val isImeVisible = WindowInsets.isImeVisible
LaunchedEffect(isImeVisible) {
shouldRequestFocus = isImeVisible
}
RecipientSearchBar(
query = searchQuery,
onQueryChange = { filter -> callbacks.listActions.onSearchQueryChanged(query = filter) },
onSearch = {},
modifier = Modifier
.focusRequester(focusRequester)
.fillMaxWidth()
.padding(horizontal = 16.dp)
)
@@ -104,54 +121,7 @@ fun RecipientPicker(
}
}
/**
* A search input field for finding recipients.
*
* Intended to be a compose-based replacement for [ContactFilterView].
*/
@Composable
private fun RecipientSearchField(
searchQuery: String,
onFilterChanged: (String) -> Unit,
@StringRes hintText: Int? = null,
focusAndShowKeyboard: Boolean = false,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
val wrappedView = remember {
ContactFilterView(context, null, 0).apply {
hintText?.let { setHint(it) }
}
}
LaunchedEffect(searchQuery) {
wrappedView.setText(searchQuery)
}
// TODO [jeff] This causes the keyboard to re-open on rotation, which doesn't match the existing behavior of ContactFilterView. To fix this,
// RecipientSearchField needs to be converted to compose so we can use FocusRequestor.
LaunchedEffect(focusAndShowKeyboard) {
if (focusAndShowKeyboard) {
wrappedView.focusAndShowKeyboard()
} else {
wrappedView.clearFocus()
ViewUtil.hideKeyboard(wrappedView.context, wrappedView)
}
}
DisposableEffect(onFilterChanged) {
wrappedView.setOnFilterChangedListener { filter -> onFilterChanged(filter) }
onDispose {
wrappedView.setOnFilterChangedListener(null)
}
}
AndroidView(
factory = { wrappedView },
modifier = modifier
)
}
@Suppress("KotlinConstantConditions")
@Composable
private fun RecipientSearchResultsList(
displayModes: Set<RecipientPicker.DisplayMode>,
@@ -179,6 +149,7 @@ private fun RecipientSearchResultsList(
val fragmentState = rememberFragmentState()
var currentFragment by remember { mutableStateOf<ContactSelectionListFragment?>(null) }
val coroutineScope = rememberCoroutineScope()
val focusManager = LocalFocusManager.current
Fragments.Fragment<ContactSelectionListFragment>(
arguments = fragmentArgs,
@@ -188,6 +159,7 @@ private fun RecipientSearchResultsList(
fragment.view?.setPadding(0, 0, 0, 0)
fragment.setUpCallbacks(
callbacks = callbacks,
clearFocus = { focusManager.clearFocus() },
coroutineScope = coroutineScope
)
},
@@ -241,6 +213,7 @@ private fun RecipientSearchResultsList(
private fun ContactSelectionListFragment.setUpCallbacks(
callbacks: RecipientPickerCallbacks,
clearFocus: () -> Unit,
coroutineScope: CoroutineScope
) {
val fragment: ContactSelectionListFragment = this
@@ -302,9 +275,7 @@ private fun ContactSelectionListFragment.setUpCallbacks(
}
fragment.setOnRefreshListener { callbacks.refresh?.onRefresh() }
fragment.setScrollCallback {
fragment.view?.let { view -> ViewUtil.hideKeyboard(view.context, view) }
}
fragment.setScrollCallback { clearFocus() }
}
private suspend fun showItemContextMenu(

View File

@@ -0,0 +1,170 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.conversation
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SearchBar
import androidx.compose.material3.SearchBarDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.rememberSearchBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.IconButtons.IconButton
import org.signal.core.ui.compose.theme.SignalTheme
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.compose.ProvideIncognitoKeyboard
import org.thoughtcrime.securesms.util.TextSecurePreferences
/**
* A search input field for finding recipients.
*
* Replaces [org.thoughtcrime.securesms.components.ContactFilterView].
*/
@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class, ExperimentalComposeUiApi::class)
@Composable
fun RecipientSearchBar(
hint: String = stringResource(R.string.RecipientSearchBar__search_name_or_number),
query: String,
onQueryChange: (String) -> Unit,
onSearch: (String) -> Unit,
modifier: Modifier = Modifier
) {
val state = rememberSearchBarState()
var keyboardOptions by remember {
mutableStateOf(
KeyboardOptions(
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Search
)
)
}
ProvideIncognitoKeyboard(
enabled = TextSecurePreferences.isIncognitoKeyboardEnabled(LocalContext.current)
) {
SearchBar(
state = state,
inputField = {
TextField(
value = query,
onValueChange = onQueryChange,
placeholder = { Text(hint) },
singleLine = true,
shape = SearchBarDefaults.inputFieldShape,
colors = TextFieldDefaults.colors(
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant,
focusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent
),
keyboardOptions = keyboardOptions,
keyboardActions = KeyboardActions(
onSearch = { onSearch(query) }
),
trailingIcon = {
val modifier = Modifier.padding(end = 4.dp)
if (query.isNotEmpty()) {
ClearQueryButton(
onClearQuery = { onQueryChange("") },
modifier = modifier
)
} else {
KeyboardToggleButton(
keyboardType = keyboardOptions.keyboardType,
onKeyboardTypeChange = { keyboardOptions = keyboardOptions.copy(keyboardType = it) },
modifier = modifier
)
}
}
)
},
modifier = modifier
)
}
}
@Composable
private fun KeyboardToggleButton(
keyboardType: KeyboardType,
onKeyboardTypeChange: (KeyboardType) -> Unit = {},
modifier: Modifier = Modifier
) {
IconButton(
onClick = {
onKeyboardTypeChange(
when (keyboardType) {
KeyboardType.Text -> KeyboardType.Phone
else -> KeyboardType.Text
}
)
},
modifier = modifier
) {
when (keyboardType) {
KeyboardType.Text -> Icon(
imageVector = ImageVector.vectorResource(R.drawable.ic_number_pad_conversation_filter_24),
tint = MaterialTheme.colorScheme.onSurface,
contentDescription = stringResource(R.string.RecipientSearchBar_accessibility_switch_to_numeric_keyboard)
)
else -> Icon(
imageVector = ImageVector.vectorResource(R.drawable.ic_keyboard_24),
tint = MaterialTheme.colorScheme.onSurface,
contentDescription = stringResource(R.string.RecipientSearchBar_accessibility_switch_to_alphanumeric_keyboard)
)
}
}
}
@Composable
private fun ClearQueryButton(
onClearQuery: () -> Unit,
modifier: Modifier = Modifier
) {
IconButton(
onClick = { onClearQuery() },
modifier = modifier
) {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.ic_x_conversation_filter_24),
tint = MaterialTheme.colorScheme.onSurface,
contentDescription = stringResource(R.string.RecipientSearchBar_accessibility_clear_search)
)
}
}
@Composable
@DayNightPreviews
private fun RecipientSearchBarPreview() = SignalTheme {
RecipientSearchBar(
query = "",
onQueryChange = {},
onSearch = {}
)
}

View File

@@ -4,9 +4,8 @@ import android.widget.EditText
import androidx.appcompat.widget.SearchView
/**
* Since this value is only supported on API26+ we hard-code it here
* to avoid issues with older versions. This mirrors the approach
* taken by [org.thoughtcrime.securesms.components.ComposeText].
* Since [android.view.inputmethod.EditorInfo.IME_FLAG_NO_PERSONALIZED_LEARNING] is only supported on API 26+ we hard-code it here
* to avoid issues with older versions. This mirrors the approach taken by [org.thoughtcrime.securesms.components.ComposeText].
*/
private const val INCOGNITO_KEYBOARD = 16777216

View File

@@ -29,7 +29,7 @@
android:background="@android:color/transparent"
android:fontFamily="sans-serif"
android:gravity="center_vertical"
android:hint="@string/ContactFilterView__search_name_or_number"
android:hint="@string/RecipientSearchBar__search_name_or_number"
android:inputType="textPersonName"
android:lineSpacingExtra="6sp"
android:textAppearance="@style/TextSecure.TitleTextStyle"

View File

@@ -5931,8 +5931,14 @@
<item quantity="other">%1$s are not Signal users</item>
</plurals>
<!-- 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>
<!-- Hint text that shows in the recipient search bar when no text is entered yet. -->
<string name="RecipientSearchBar__search_name_or_number">Name, username or number</string>
<!-- Accessibility label for the button to switch to the numeric keyboard type. -->
<string name="RecipientSearchBar_accessibility_switch_to_numeric_keyboard">Switch to numeric keyboard</string>
<!-- Accessibility label for the button to switch to the alphanumeric keyboard type. -->
<string name="RecipientSearchBar_accessibility_switch_to_alphanumeric_keyboard">Switch to alphanumeric keyboard</string>
<!-- Accessibility label for the button to clear the search filter. -->
<string name="RecipientSearchBar_accessibility_clear_search">Reset search filter</string>
<!-- VoiceNotePlayerView -->
<string name="VoiceNotePlayerView__dot_s">· %1$s</string>