mirror of
https://github.com/signalapp/Signal-Android.git
synced 2025-12-22 12:08:34 +00:00
Convert ContactFilterView to compose.
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user