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. * 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 { public final class ContactFilterView extends FrameLayout {
private OnFilterChangedListener listener; 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.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Column 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.isImeVisible
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -22,12 +23,13 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier 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.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity 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.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.fragment.compose.rememberFragmentState import androidx.fragment.compose.rememberFragmentState
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -40,7 +42,6 @@ import org.signal.core.util.DimensionUnit
import org.signal.core.util.orNull import org.signal.core.util.orNull
import org.thoughtcrime.securesms.ContactSelectionListFragment import org.thoughtcrime.securesms.ContactSelectionListFragment
import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.ContactFilterView
import org.thoughtcrime.securesms.components.menu.ActionItem import org.thoughtcrime.securesms.components.menu.ActionItem
import org.thoughtcrime.securesms.components.menu.SignalContextMenu import org.thoughtcrime.securesms.components.menu.SignalContextMenu
import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode 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.PhoneNumber
import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.ViewUtil
import java.util.Optional import java.util.Optional
import java.util.function.Consumer import java.util.function.Consumer
/** /**
* Provides a recipient search and selection UI. * Provides a recipient search and selection UI.
*/ */
@Suppress("KotlinConstantConditions")
@OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
fun RecipientPicker( fun RecipientPicker(
searchQuery: String, searchQuery: String,
@@ -78,11 +80,26 @@ fun RecipientPicker(
Column( Column(
modifier = modifier modifier = modifier
) { ) {
RecipientSearchField( val focusRequester = remember { FocusRequester() }
searchQuery = searchQuery, var shouldRequestFocus by rememberSaveable { mutableStateOf(focusAndShowKeyboard) }
onFilterChanged = { filter -> callbacks.listActions.onSearchQueryChanged(query = filter) },
focusAndShowKeyboard = 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 modifier = Modifier
.focusRequester(focusRequester)
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
) )
@@ -104,54 +121,7 @@ fun RecipientPicker(
} }
} }
/** @Suppress("KotlinConstantConditions")
* 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
)
}
@Composable @Composable
private fun RecipientSearchResultsList( private fun RecipientSearchResultsList(
displayModes: Set<RecipientPicker.DisplayMode>, displayModes: Set<RecipientPicker.DisplayMode>,
@@ -179,6 +149,7 @@ private fun RecipientSearchResultsList(
val fragmentState = rememberFragmentState() val fragmentState = rememberFragmentState()
var currentFragment by remember { mutableStateOf<ContactSelectionListFragment?>(null) } var currentFragment by remember { mutableStateOf<ContactSelectionListFragment?>(null) }
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val focusManager = LocalFocusManager.current
Fragments.Fragment<ContactSelectionListFragment>( Fragments.Fragment<ContactSelectionListFragment>(
arguments = fragmentArgs, arguments = fragmentArgs,
@@ -188,6 +159,7 @@ private fun RecipientSearchResultsList(
fragment.view?.setPadding(0, 0, 0, 0) fragment.view?.setPadding(0, 0, 0, 0)
fragment.setUpCallbacks( fragment.setUpCallbacks(
callbacks = callbacks, callbacks = callbacks,
clearFocus = { focusManager.clearFocus() },
coroutineScope = coroutineScope coroutineScope = coroutineScope
) )
}, },
@@ -241,6 +213,7 @@ private fun RecipientSearchResultsList(
private fun ContactSelectionListFragment.setUpCallbacks( private fun ContactSelectionListFragment.setUpCallbacks(
callbacks: RecipientPickerCallbacks, callbacks: RecipientPickerCallbacks,
clearFocus: () -> Unit,
coroutineScope: CoroutineScope coroutineScope: CoroutineScope
) { ) {
val fragment: ContactSelectionListFragment = this val fragment: ContactSelectionListFragment = this
@@ -302,9 +275,7 @@ private fun ContactSelectionListFragment.setUpCallbacks(
} }
fragment.setOnRefreshListener { callbacks.refresh?.onRefresh() } fragment.setOnRefreshListener { callbacks.refresh?.onRefresh() }
fragment.setScrollCallback { fragment.setScrollCallback { clearFocus() }
fragment.view?.let { view -> ViewUtil.hideKeyboard(view.context, view) }
}
} }
private suspend fun showItemContextMenu( 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 import androidx.appcompat.widget.SearchView
/** /**
* Since this value is only supported on API26+ we hard-code it here * 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 * to avoid issues with older versions. This mirrors the approach taken by [org.thoughtcrime.securesms.components.ComposeText].
* taken by [org.thoughtcrime.securesms.components.ComposeText].
*/ */
private const val INCOGNITO_KEYBOARD = 16777216 private const val INCOGNITO_KEYBOARD = 16777216

View File

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

View File

@@ -5931,8 +5931,14 @@
<item quantity="other">%1$s are not Signal users</item> <item quantity="other">%1$s are not Signal users</item>
</plurals> </plurals>
<!-- Hint text that shows in the recipient search box when no text is entered yet. --> <!-- Hint text that shows in the recipient search bar when no text is entered yet. -->
<string name="ContactFilterView__search_name_or_number">Name, username or number</string> <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 --> <!-- VoiceNotePlayerView -->
<string name="VoiceNotePlayerView__dot_s">· %1$s</string> <string name="VoiceNotePlayerView__dot_s">· %1$s</string>