From 522503cf97001676e0a742e7d1d59f4521cc821b Mon Sep 17 00:00:00 2001 From: Jakub Zerko Date: Tue, 19 May 2026 15:42:47 +0200 Subject: [PATCH] fix: keyboard navigation in conversations UI --- .../com/wire/android/ui/home/HomeScreen.kt | 87 +++-- .../com/wire/android/ui/home/HomeTopBar.kt | 21 +- .../search/SearchUsersAndAppsScreen.kt | 7 +- .../home/messagecomposer/AdditionalOptions.kt | 21 +- .../home/messagecomposer/AttachmentOptions.kt | 19 +- .../messagecomposer/EnabledMessageComposer.kt | 36 +- .../messagecomposer/MessageComposeActions.kt | 14 +- .../messagecomposer/MessageComposerInput.kt | 63 +++- .../MessageComposerKeyboardNavigationState.kt | 103 ++++++ .../attachments/AdditionalOptionButton.kt | 30 +- .../com/wire/android/ui/common/Extensions.kt | 22 ++ .../com/wire/android/ui/common/SearchBar.kt | 4 + .../ui/common/textfield/WireTextField.kt | 6 +- .../common/topappbar/search/SearchTopBar.kt | 334 ++++++++++++++---- kalium | 2 +- 15 files changed, 646 insertions(+), 123 deletions(-) create mode 100644 app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposerKeyboardNavigationState.kt diff --git a/app/src/main/kotlin/com/wire/android/ui/home/HomeScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/HomeScreen.kt index eaa00a1ee51..15a5674b4be 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/HomeScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/HomeScreen.kt @@ -49,6 +49,9 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.layout.ContentScale @@ -265,6 +268,8 @@ fun HomeContent( modifier: Modifier = Modifier, ) { val context = LocalContext.current + val searchFocusRequester = remember { FocusRequester() } + val fabFocusRequester = remember { FocusRequester() } with(homeStateHolder) { fun openWireHomeDestination(item: HomeDestination) { @@ -329,6 +334,8 @@ fun HomeContent( onOpenConversationFilter = { homeStateHolder.conversationsFilterBottomSheetState.show(Unit) }, + searchFocusRequester = searchFocusRequester, + fabFocusRequester = if (currentNavigationItem.fab != null) fabFocusRequester else null, ) } }, @@ -343,7 +350,13 @@ fun HomeContent( isSearchActive = searchBarState.isSearchActive, searchBarHint = stringResource(searchBar.hint), searchQueryTextState = searchBarState.searchQueryTextState, - onActiveChanged = searchBarState::searchActiveChanged, + onCloseSearchClicked = searchBarState::closeSearch, + onActiveChanged = { isFocused -> + if (isFocused) { + searchBarState.openSearch() + } + }, + externalFocusRequester = searchFocusRequester, ) } } @@ -388,30 +401,12 @@ fun HomeContent( enter = scaleIn(), exit = scaleOut(), ) { - var currentFab by remember { mutableStateOf(currentNavigationItem.fab ?: FabOptions.NewConversation) } - // to keep the fab during the exit animation, we need to keep last known (non-null) fab data - if (currentNavigationItem.fab != null) currentFab = currentNavigationItem.fab!! - - FloatingActionButton( - text = stringResource(currentFab.text), - icon = { - Image( - painter = painterResource(currentFab.icon), - contentDescription = stringResource(currentFab.contentDescription), - contentScale = ContentScale.FillBounds, - colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onPrimary), - modifier = Modifier - .padding(start = dimensions().spacing4x, top = dimensions().spacing2x) - .size(dimensions().fabIconSize) - ) - }, - onClick = { - when (currentNavigationItem.fab) { - FabOptions.NewConversation -> onNewConversationClick() - FabOptions.NewMeeting -> homeStateHolder.newMeetingBottomSheetState.show(Unit) - else -> { /* no-op */ } - } - } + HomeFloatingActionButton( + currentNavigationItem = currentNavigationItem, + fabFocusRequester = fabFocusRequester, + searchFocusRequester = searchFocusRequester, + onNewConversationClick = onNewConversationClick, + onNewMeetingClick = { homeStateHolder.newMeetingBottomSheetState.show(Unit) } ) } } @@ -420,3 +415,45 @@ fun HomeContent( ) } } + +@Composable +private fun HomeFloatingActionButton( + currentNavigationItem: HomeDestination, + fabFocusRequester: FocusRequester, + searchFocusRequester: FocusRequester, + onNewConversationClick: () -> Unit, + onNewMeetingClick: () -> Unit, +) { + var currentFab by remember { mutableStateOf(currentNavigationItem.fab ?: FabOptions.NewConversation) } + // to keep the fab during the exit animation, we need to keep last known (non-null) fab data + currentNavigationItem.fab?.let { currentFab = it } + + FloatingActionButton( + text = stringResource(currentFab.text), + modifier = Modifier + .focusRequester(fabFocusRequester) + .focusProperties { + if (currentNavigationItem.searchBar != null) { + next = searchFocusRequester + } + }, + icon = { + Image( + painter = painterResource(currentFab.icon), + contentDescription = stringResource(currentFab.contentDescription), + contentScale = ContentScale.FillBounds, + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onPrimary), + modifier = Modifier + .padding(start = dimensions().spacing4x, top = dimensions().spacing2x) + .size(dimensions().fabIconSize) + ) + }, + onClick = { + when (currentNavigationItem.fab) { + FabOptions.NewConversation -> onNewConversationClick() + FabOptions.NewMeeting -> onNewMeetingClick() + else -> { /* no-op */ } + } + } + ) +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/HomeTopBar.kt b/app/src/main/kotlin/com/wire/android/ui/home/HomeTopBar.kt index b91d978594f..cae9d63321a 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/HomeTopBar.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/HomeTopBar.kt @@ -20,6 +20,9 @@ package com.wire.android.ui.home import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -52,9 +55,13 @@ fun HomeTopBar( onHamburgerMenuClick: () -> Unit, onNavigateToSelfUserProfile: () -> Unit, onOpenConversationFilter: () -> Unit, + modifier: Modifier = Modifier, + searchFocusRequester: FocusRequester? = null, + fabFocusRequester: FocusRequester? = null, ) { WireCenterAlignedTopAppBar( title = title, + modifier = modifier, onNavigationPressed = onHamburgerMenuClick, navigationIconType = NavigationIconType.Menu, actions = { @@ -82,11 +89,19 @@ fun HomeTopBar( } UserProfileAvatar( avatarData = userAvatarData, - clickable = remember { + modifier = Modifier + .focusProperties { + when { + fabFocusRequester != null -> next = fabFocusRequester + searchFocusRequester != null -> next = searchFocusRequester + } + }, + clickable = remember(openLabel, onNavigateToSelfUserProfile) { Clickable( enabled = true, - onClickDescription = openLabel - ) { onNavigateToSelfUserProfile() } + onClickDescription = openLabel, + onClick = onNavigateToSelfUserProfile + ) }, type = UserProfileAvatarType.WithIndicators.RegularUser( legalHoldIndicatorVisible = withLegalHoldIndicator diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/SearchUsersAndAppsScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/SearchUsersAndAppsScreen.kt index 80878b665d9..07e485b983c 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/SearchUsersAndAppsScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/SearchUsersAndAppsScreen.kt @@ -148,7 +148,12 @@ fun SearchUsersAndAppsScreen( backIconContentDescription = stringResource(id = R.string.content_description_add_participants_back_btn), searchBarDescription = stringResource(R.string.content_description_add_participants_search_field), searchQueryTextState = searchBarState.searchQueryTextState, - onActiveChanged = searchBarState::searchActiveChanged, + onCloseSearchClicked = searchBarState::closeSearch, + onActiveChanged = { isFocused -> + if (isFocused) { + searchBarState.openSearch() + } + }, ) }, topBarFooter = { diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/AdditionalOptions.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/AdditionalOptions.kt index 9d16458a4e0..7b6feb76bc7 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/AdditionalOptions.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/AdditionalOptions.kt @@ -27,7 +27,9 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester import com.wire.android.ui.common.colorsScheme +import com.wire.android.ui.common.onEscapeOrBackKey import com.wire.android.ui.home.conversations.ConversationActionPermissionType import com.wire.android.ui.home.conversations.model.UriAsset import com.wire.android.ui.home.messagecomposer.recordaudio.RecordAudioComponent @@ -56,10 +58,18 @@ fun AdditionalOptionsMenu( onRichOptionButtonClicked: (RichTextMarkdown) -> Unit, onDrawingModeClicked: () -> Unit, modifier: Modifier = Modifier, + initialKeyboardFocusRequester: FocusRequester? = null, onOnSelfDeletingOptionClicked: ((SelfDeletionTimer) -> Unit)? = null, onGifOptionClicked: (() -> Unit)? = null ) { - Box(modifier.background(colorsScheme().surface)) { + Box( + modifier + .onEscapeOrBackKey( + enabled = additionalOptionsState == AdditionalOptionMenuState.RichTextEditing, + onKeyPressed = onCloseRichEditingButtonClicked + ) + .background(colorsScheme().surface) + ) { when (additionalOptionsState) { AdditionalOptionMenuState.AttachmentAndAdditionalOptionsMenu -> { AttachmentAndAdditionalOptionsMenuItems( @@ -75,7 +85,8 @@ fun AdditionalOptionsMenu( onRichEditingButtonClicked = onRichEditingButtonClicked, onPingClicked = onPingOptionClicked, onDrawingModeClicked = onDrawingModeClicked, - isFileSharingEnabled = isFileSharingEnabled + isFileSharingEnabled = isFileSharingEnabled, + initialKeyboardFocusRequester = initialKeyboardFocusRequester ) } @@ -146,7 +157,8 @@ fun AttachmentAndAdditionalOptionsMenuItems( onPingClicked: () -> Unit = {}, onGifButtonClicked: () -> Unit = {}, onRichEditingButtonClicked: () -> Unit = {}, - onDrawingModeClicked: () -> Unit = {} + onDrawingModeClicked: () -> Unit = {}, + initialKeyboardFocusRequester: FocusRequester? = null ) { Column(modifier.wrapContentSize()) { HorizontalDivider(color = MaterialTheme.wireColorScheme.outline) @@ -163,7 +175,8 @@ fun AttachmentAndAdditionalOptionsMenuItems( onGifButtonClicked = onGifButtonClicked, onRichEditingButtonClicked = onRichEditingButtonClicked, onDrawingModeClicked = onDrawingModeClicked, - isFileSharingEnabled = isFileSharingEnabled + isFileSharingEnabled = isFileSharingEnabled, + initialKeyboardFocusRequester = initialKeyboardFocusRequester ) } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/AttachmentOptions.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/AttachmentOptions.kt index a9ea0fb8e8b..42454957bb8 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/AttachmentOptions.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/AttachmentOptions.kt @@ -42,6 +42,8 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.scale +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.rememberTextMeasurer @@ -77,6 +79,7 @@ fun AttachmentOptionsComponent( ) { val density = LocalDensity.current val textMeasurer = rememberTextMeasurer() + val firstOptionFocusRequester = remember { FocusRequester() } val attachmentOptions = buildAttachmentOptionItems( isFileSharingEnabled = isFileSharingEnabled, @@ -123,6 +126,12 @@ fun AttachmentOptionsComponent( val (columns, contentPadding) = params val numberOfColumns = (fullWidth / minColumnWidth).toInt() + LaunchedEffect(optionsVisible, visibleAttachmentOptions.size) { + if (optionsVisible && visibleAttachmentOptions.isNotEmpty()) { + firstOptionFocusRequester.requestFocus() + } + } + LazyVerticalGrid( columns = columns, modifier = Modifier.fillMaxSize(), @@ -162,7 +171,15 @@ fun AttachmentOptionsComponent( AttachmentButton( icon = option.icon, labelStyle = labelStyle, - modifier = Modifier.scale(animatedScale), + modifier = Modifier + .scale(animatedScale) + .then( + if (index == 0) { + Modifier.focusRequester(firstOptionFocusRequester) + } else { + Modifier + } + ), text = stringResource(option.text) ) { option.onClick() } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/EnabledMessageComposer.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/EnabledMessageComposer.kt index 93d95467d34..7a8055379a5 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/EnabledMessageComposer.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/EnabledMessageComposer.kt @@ -59,6 +59,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect @@ -95,6 +97,7 @@ import com.wire.android.ui.common.textfield.MessageComposerEnterToSend import com.wire.android.ui.home.conversations.ConversationActionPermissionType import com.wire.android.ui.home.conversations.UsersTypingIndicatorForConversation import com.wire.android.ui.home.conversations.model.UriAsset +import com.wire.android.ui.home.messagecomposer.state.AdditionalOptionMenuState import com.wire.android.ui.home.messagecomposer.state.AdditionalOptionSubMenuState import com.wire.android.ui.home.messagecomposer.state.InputType import com.wire.android.ui.home.messagecomposer.state.MessageComposerStateHolder @@ -130,12 +133,14 @@ fun EnabledMessageComposer( ) { val context = LocalContext.current val density = LocalDensity.current + val additionalOptionsFocusRequester = remember { FocusRequester() } val navBarHeight = bottomNavigationBarHeight() val isImeVisible = WindowInsets.isImeVisible val offsetY = WindowInsets.ime.getBottom(density) val imeAnimationTarget = WindowInsets.imeAnimationTarget.getBottom(density) val rippleProgress = remember { Animatable(0f) } var hideRipple by remember { mutableStateOf(true) } + var isKeyboardNavigationMenuVisible by remember { mutableStateOf(false) } with(messageComposerStateHolder) { val inputStateHolder = messageCompositionInputStateHolder @@ -167,6 +172,12 @@ fun EnabledMessageComposer( } } + LaunchedEffect(isKeyboardNavigationMenuVisible) { + if (isKeyboardNavigationMenuVisible) { + additionalOptionsFocusRequester.requestFocus() + } + } + Surface( modifier = modifier, color = colorsScheme().surface @@ -268,7 +279,14 @@ fun EnabledMessageComposer( isTextExpanded = inputStateHolder.isTextExpanded, inputType = messageCompositionInputStateHolder.inputType, focusRequester = messageCompositionInputStateHolder.focusRequester, - onFocused = ::onInputFocused, + additionalOptionsFocusRequester = additionalOptionsFocusRequester, + onAdditionalOptionsFocusRequested = { + isKeyboardNavigationMenuVisible = true + }, + onFocused = { + isKeyboardNavigationMenuVisible = false + onInputFocused() + }, onToggleInputSize = messageCompositionInputStateHolder::toggleInputSize, onCancelReply = messageCompositionHolder.value::clearReply, onCancelEdit = ::cancelEdit, @@ -284,7 +302,7 @@ fun EnabledMessageComposer( onSelectedLineIndexChanged = { index -> currentSelectedLineIndex = index }, - showOptions = isImeVisible, + showOptions = isImeVisible || isKeyboardNavigationMenuVisible, optionsSelected = inputStateHolder.optionsVisible, onPlusClick = { if (!hideRipple) { @@ -366,7 +384,7 @@ fun EnabledMessageComposer( ) } - AnimatedVisibility(isImeVisible) { + AnimatedVisibility(isImeVisible || isKeyboardNavigationMenuVisible) { AdditionalOptionsMenu( conversationId = conversationId, additionalOptionsState = additionalOptionStateHolder.additionalOptionState, @@ -389,7 +407,13 @@ fun EnabledMessageComposer( }, onCloseRichEditingButtonClicked = additionalOptionStateHolder::toAttachmentAndAdditionalOptionsMenu, onDrawingModeClicked = openDrawingCanvas, - isFileSharingEnabled = messageComposerViewState.value.isFileSharingEnabled + isFileSharingEnabled = messageComposerViewState.value.isFileSharingEnabled, + initialKeyboardFocusRequester = additionalOptionsFocusRequester, + modifier = Modifier.onFocusChanged { + if (it.hasFocus) { + isKeyboardNavigationMenuVisible = true + } + } ) } } @@ -398,6 +422,7 @@ fun EnabledMessageComposer( Popup( alignment = Alignment.BottomCenter, properties = PopupProperties( + focusable = true, dismissOnBackPress = true, dismissOnClickOutside = true ), @@ -483,6 +508,9 @@ fun EnabledMessageComposer( BackHandler(isImeVisible || inputStateHolder.inputFocused) { inputStateHolder.collapseComposer(additionalOptionStateHolder.additionalOptionsSubMenuState) } + BackHandler(additionalOptionStateHolder.additionalOptionState == AdditionalOptionMenuState.RichTextEditing) { + additionalOptionStateHolder.toAttachmentAndAdditionalOptionsMenu() + } } } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposeActions.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposeActions.kt index 0f00dd74f6f..c2f0d1abd25 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposeActions.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposeActions.kt @@ -29,6 +29,8 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.tooling.preview.Preview import com.wire.android.R import com.wire.android.di.hiltViewModelScoped @@ -59,8 +61,9 @@ fun MessageComposeActions( onGifButtonClicked: () -> Unit, onRichEditingButtonClicked: () -> Unit, isFileSharingEnabled: Boolean, + onDrawingModeClicked: () -> Unit, isMentionActive: Boolean = true, - onDrawingModeClicked: () -> Unit + initialKeyboardFocusRequester: FocusRequester? = null ) { if (isEditing) { EditingActions( @@ -82,7 +85,8 @@ fun MessageComposeActions( onPingButtonClicked = onPingButtonClicked, onMentionButtonClicked = onMentionButtonClicked, onDrawingModeClicked = onDrawingModeClicked, - isFileSharingEnabled = isFileSharingEnabled + isFileSharingEnabled = isFileSharingEnabled, + initialKeyboardFocusRequester = initialKeyboardFocusRequester ) } } @@ -100,7 +104,8 @@ private fun ComposingActions( onSelfDeletionOptionButtonClicked: (SelfDeletionTimer) -> Unit, onPingButtonClicked: () -> Unit, onMentionButtonClicked: () -> Unit, - onDrawingModeClicked: () -> Unit + onDrawingModeClicked: () -> Unit, + initialKeyboardFocusRequester: FocusRequester? ) { val localFeatureVisibilityFlags = LocalFeatureVisibilityFlags.current @@ -114,7 +119,8 @@ private fun ComposingActions( with(localFeatureVisibilityFlags) { AdditionalOptionButton( isSelected = attachmentsVisible, - onClick = onAdditionalOptionButtonClicked + onClick = onAdditionalOptionButtonClicked, + modifier = initialKeyboardFocusRequester?.let { Modifier.focusRequester(it) } ?: Modifier ) RichTextEditingAction( isSelected = selectedOption == AdditionalOptionSelectItem.RichTextEditing, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposerInput.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposerInput.kt index 75744573d2b..ae13d6bf961 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposerInput.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposerInput.kt @@ -46,8 +46,11 @@ import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -102,6 +105,8 @@ fun ActiveMessageComposerInput( optionsSelected: Boolean, onPlusClick: () -> Unit, modifier: Modifier = Modifier, + additionalOptionsFocusRequester: FocusRequester? = null, + onAdditionalOptionsFocusRequested: () -> Unit = {}, ) { Column( modifier = modifier @@ -132,6 +137,8 @@ fun ActiveMessageComposerInput( isTextExpanded = isTextExpanded, inputType = inputType, focusRequester = focusRequester, + additionalOptionsFocusRequester = additionalOptionsFocusRequester, + onAdditionalOptionsFocusRequested = onAdditionalOptionsFocusRequested, onSendButtonClicked = onSendButtonClicked, keyboardOptions = keyboardOptions, onKeyboardAction = onKeyboardAction, @@ -171,6 +178,8 @@ private fun InputContent( isTextExpanded: Boolean, inputType: InputType, focusRequester: FocusRequester, + additionalOptionsFocusRequester: FocusRequester?, + onAdditionalOptionsFocusRequested: () -> Unit, keyboardOptions: KeyboardOptions, onKeyboardAction: KeyboardActionHandler?, canSendMessage: Boolean, @@ -215,6 +224,8 @@ private fun InputContent( val collapsedMaxHeight = dimensions().messageComposerActiveInputMaxHeight MessageComposerTextInput( focusRequester = focusRequester, + additionalOptionsFocusRequester = additionalOptionsFocusRequester, + onAdditionalOptionsFocusRequested = onAdditionalOptionsFocusRequested, colors = inputType.inputTextColor(isSelfDeleting = viewModel.state().duration != null), messageTextState = messageTextState, placeHolderText = viewModel.state().duration?.let { stringResource(id = R.string.self_deleting_message_label) } @@ -224,6 +235,8 @@ private fun InputContent( onLineBottomYCoordinateChanged = onLineBottomYCoordinateChanged, keyboardOptions = keyboardOptions, onKeyBoardAction = onKeyboardAction, + canSendMessage = canSendMessage, + onSendButtonClicked = onSendButtonClicked, modifier = Modifier .fillMaxWidth() .constrainAs(input) { @@ -275,17 +288,35 @@ private fun InputContent( private fun MessageComposerTextInput( messageTextState: TextFieldState, focusRequester: FocusRequester, + additionalOptionsFocusRequester: FocusRequester?, + onAdditionalOptionsFocusRequested: () -> Unit, colors: WireTextFieldColors, placeHolderText: String, onFocused: () -> Unit, keyboardOptions: KeyboardOptions, onKeyBoardAction: KeyboardActionHandler?, + canSendMessage: Boolean, modifier: Modifier = Modifier, onSelectedLineIndexChanged: (Int) -> Unit = { }, onLineBottomYCoordinateChanged: (Float) -> Unit = { }, + onSendButtonClicked: () -> Unit, ) { val interactionSource = remember { MutableInteractionSource() } val isPressed by interactionSource.collectIsPressedAsState() + val focusManager = LocalFocusManager.current + val keyboardNavigationState = MessageComposerKeyboardNavigationState( + focusManager = focusManager, + additionalOptionsFocusRequester = additionalOptionsFocusRequester, + messageTextState = messageTextState, + canSendMessage = canSendMessage + ) + val keyboardFocusProperties = if (additionalOptionsFocusRequester != null) { + Modifier.focusProperties { + next = additionalOptionsFocusRequester + } + } else { + Modifier + } LaunchedEffect(isPressed) { if (isPressed) { @@ -293,6 +324,18 @@ private fun MessageComposerTextInput( } } + LaunchedEffect(messageTextState.text) { + val text = messageTextState.text + if (text.lastOrNull() == '\n') { + messageTextState.edit { + replace(text.length - 1, text.length, "") + } + if (canSendMessage) { + onSendButtonClicked() + } + } + } + WireTextField( textState = messageTextState, colors = colors, @@ -303,13 +346,30 @@ private fun MessageComposerTextInput( keyboardOptions = keyboardOptions, onKeyboardAction = onKeyBoardAction, modifier = modifier - .focusable(true) + .then(keyboardFocusProperties) .focusRequester(focusRequester) + .onPreviewKeyEvent { event -> + handleKeyboardNavigation( + event = event, + state = keyboardNavigationState, + onAdditionalOptionsFocusRequested = onAdditionalOptionsFocusRequested, + onSendButtonClicked = onSendButtonClicked + ) + } + .focusable(true) .onFocusChanged { focusState -> if (focusState.isFocused) { onFocused() } }, + inputModifier = keyboardFocusProperties.onPreviewKeyEvent { event -> + handleKeyboardNavigation( + event = event, + state = keyboardNavigationState, + onAdditionalOptionsFocusRequested = onAdditionalOptionsFocusRequested, + onSendButtonClicked = onSendButtonClicked + ) + }, interactionSource = interactionSource, onSelectedLineIndexChanged = onSelectedLineIndexChanged, onLineBottomYCoordinateChanged = onLineBottomYCoordinateChanged, @@ -363,6 +423,7 @@ private fun PreviewActiveMessageComposerInput(inputType: InputType, isTextExpand onKeyboardAction = null, canSendMessage = true, focusRequester = remember { FocusRequester() }, + additionalOptionsFocusRequester = remember { FocusRequester() }, onSendButtonClicked = {}, onEditButtonClicked = {}, onChangeSelfDeletionClicked = {}, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposerKeyboardNavigationState.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposerKeyboardNavigationState.kt new file mode 100644 index 00000000000..46d93c95e2d --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposerKeyboardNavigationState.kt @@ -0,0 +1,103 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.home.messagecomposer + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.insert +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.focus.FocusManager +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.isShiftPressed +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.type + +data class MessageComposerKeyboardNavigationState( + val focusManager: FocusManager, + val additionalOptionsFocusRequester: FocusRequester?, + val messageTextState: TextFieldState, + val canSendMessage: Boolean, +) + +fun handleKeyboardNavigation( + event: KeyEvent, + state: MessageComposerKeyboardNavigationState, + onAdditionalOptionsFocusRequested: () -> Unit, + onSendButtonClicked: () -> Unit, +): Boolean { + if (event.type != KeyEventType.KeyDown) { + return false + } + + return when (event.key) { + Key.Tab -> { + moveFocusFromComposerInput( + event = event, + focusManager = state.focusManager, + additionalOptionsFocusRequester = state.additionalOptionsFocusRequester, + onAdditionalOptionsFocusRequested = onAdditionalOptionsFocusRequested + ) + true + } + + Key.Enter -> { + handleEnterKeyInComposerInput( + event = event, + messageTextState = state.messageTextState, + canSendMessage = state.canSendMessage, + onSendButtonClicked = onSendButtonClicked + ) + true + } + + else -> false + } +} + +private fun moveFocusFromComposerInput( + event: KeyEvent, + focusManager: FocusManager, + additionalOptionsFocusRequester: FocusRequester?, + onAdditionalOptionsFocusRequested: () -> Unit, +) { + if (event.isShiftPressed) { + focusManager.moveFocus(FocusDirection.Previous) + } else { + onAdditionalOptionsFocusRequested() + if (additionalOptionsFocusRequester?.requestFocus() != true) { + focusManager.moveFocus(FocusDirection.Next) + } + } +} + +private fun handleEnterKeyInComposerInput( + event: KeyEvent, + messageTextState: TextFieldState, + canSendMessage: Boolean, + onSendButtonClicked: () -> Unit, +) { + if (event.isShiftPressed) { + messageTextState.edit { + insert(messageTextState.text.length, "\n") + } + } else if (canSendMessage) { + onSendButtonClicked() + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/attachments/AdditionalOptionButton.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/attachments/AdditionalOptionButton.kt index 2f71c1b8217..4dfd0c69357 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/attachments/AdditionalOptionButton.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/attachments/AdditionalOptionButton.kt @@ -17,7 +17,6 @@ */ package com.wire.android.ui.home.messagecomposer.attachments -import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -61,21 +60,20 @@ fun AdditionalOptionButton( enableAgain = true }) - Box(modifier = modifier) { - WireSecondaryIconButton( - onButtonClicked = { - if (enableAgain) { - enableAgain = false - onClick() - } - }, - iconResource = R.drawable.ic_add, - contentDescription = R.string.content_description_attachment_item, - state = if (!viewModel.isFileSharingEnabled()) { - WireButtonState.Disabled - } else if (isSelected) WireButtonState.Selected else WireButtonState.Default, - ) - } + WireSecondaryIconButton( + onButtonClicked = { + if (enableAgain) { + enableAgain = false + onClick() + } + }, + iconResource = R.drawable.ic_add, + contentDescription = R.string.content_description_attachment_item, + state = if (!viewModel.isFileSharingEnabled()) { + WireButtonState.Disabled + } else if (isSelected) WireButtonState.Selected else WireButtonState.Default, + modifier = modifier + ) } private const val BUTTON_CLICK_DELAY_MILLIS = 400L diff --git a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/Extensions.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/Extensions.kt index bfc8e7abf15..03db3ddda1d 100644 --- a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/Extensions.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/Extensions.kt @@ -31,6 +31,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.semantics @@ -65,6 +70,23 @@ fun Modifier.shimmerPlaceholder( shape = shape, ) +fun Modifier.onEscapeOrBackKey( + enabled: Boolean, + onKeyPressed: () -> Unit +): Modifier = onPreviewKeyEvent { event -> + if (!enabled) { + return@onPreviewKeyEvent false + } + if (event.type != KeyEventType.KeyDown) { + return@onPreviewKeyEvent false + } + if (event.key != Key.Escape && event.key != Key.Back) { + return@onPreviewKeyEvent false + } + onKeyPressed() + true +} + @Composable fun rememberClickBlockAction(clickBlockParams: ClickBlockParams, clickAction: () -> Unit): () -> Unit { val syncStateObserver = LocalSyncStateObserver.current diff --git a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/SearchBar.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/SearchBar.kt index 218f62bc401..f252bc541d1 100644 --- a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/SearchBar.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/SearchBar.kt @@ -55,18 +55,21 @@ fun SearchBarInput( leadingIcon: @Composable () -> Unit, textState: TextFieldState, modifier: Modifier = Modifier, + inputModifier: Modifier = Modifier, placeholderTextStyle: TextStyle = LocalTextStyle.current, placeholderAlignment: Alignment.Horizontal = Alignment.CenterHorizontally, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, textStyle: TextStyle = LocalTextStyle.current, textFieldState: WireTextFieldState = WireTextFieldState.Default, isLoading: Boolean = false, + inputEnabled: Boolean = true, semanticDescription: String? = null, onTap: (() -> Unit)? = null ) { WireTextField( modifier = modifier, + inputModifier = inputModifier, textState = textState, state = textFieldState, leadingIcon = { @@ -112,6 +115,7 @@ fun SearchBarInput( placeholderAlignment = placeholderAlignment, placeholderText = placeholderText, lineLimits = TextFieldLineLimits.SingleLine, + enabled = inputEnabled, semanticDescription = semanticDescription, onTap = onTap, ) diff --git a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextField.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextField.kt index 97256d7b64b..3aa98407a28 100644 --- a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextField.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextField.kt @@ -65,6 +65,7 @@ import com.wire.android.util.PreviewMultipleThemes fun WireTextField( textState: TextFieldState, modifier: Modifier = Modifier, + inputModifier: Modifier = Modifier, placeholderText: String? = null, labelText: String? = null, labelMandatoryIcon: Boolean = false, @@ -79,6 +80,7 @@ fun WireTextField( outputTransformation: OutputTransformation? = null, keyboardOptions: KeyboardOptions = KeyboardOptions.DefaultText, onKeyboardAction: KeyboardActionHandler? = null, + enabled: Boolean = true, scrollState: ScrollState = rememberScrollState(), interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, textStyle: TextStyle = MaterialTheme.wireTypography.body01, @@ -146,10 +148,10 @@ fun WireTextField( outputTransformation = outputTransformation, scrollState = scrollState, readOnly = state is WireTextFieldState.ReadOnly, - enabled = state !is WireTextFieldState.Disabled, + enabled = enabled && state !is WireTextFieldState.Disabled, cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), interactionSource = interactionSource, - modifier = textFieldModifier, + modifier = textFieldModifier.then(inputModifier), decorator = decorator, onTextLayout = onTextLayout( textState, diff --git a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/topappbar/search/SearchTopBar.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/topappbar/search/SearchTopBar.kt index aefcb1416f7..83d36f4631d 100644 --- a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/topappbar/search/SearchTopBar.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/topappbar/search/SearchTopBar.kt @@ -18,7 +18,6 @@ package com.wire.android.ui.common.topappbar.search -import androidx.compose.animation.AnimatedContent import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background import androidx.compose.foundation.focusable @@ -43,19 +42,34 @@ import androidx.compose.runtime.State import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Alignment import androidx.compose.ui.BiasAlignment import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusManager +import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusEvent +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.isShiftPressed +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextAlign import com.wire.android.ui.common.R import com.wire.android.ui.common.SearchBarInput import com.wire.android.ui.common.dimensions +import com.wire.android.ui.common.onEscapeOrBackKey import com.wire.android.ui.common.textfield.WireTextFieldState import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireColorScheme @@ -74,29 +88,51 @@ fun SearchTopBar( searchBarDescription: String? = null, onCloseSearchClicked: (() -> Unit)? = null, onActiveChanged: (isActive: Boolean) -> Unit = {}, + externalFocusRequester: FocusRequester? = null, bottomContent: @Composable ColumnScope.() -> Unit = {}, textFieldState: WireTextFieldState = WireTextFieldState.Default, onTap: (() -> Unit)? = null, ) { val interactionSource = remember { MutableInteractionSource() } - val focusManager: FocusManager = LocalFocusManager.current - val focusRequester = remember { FocusRequester() } + val keyboardController = LocalSoftwareKeyboardController.current + val focusManager = LocalFocusManager.current + val localFocusRequester = remember { FocusRequester() } + val focusRequester = resolveFocusRequester(externalFocusRequester, localFocusRequester) + val backButtonFocusRequester = remember { FocusRequester() } + val resolvedBackIconContentDescription = resolveBackIconContentDescription(backIconContentDescription) - fun setActive(isActive: Boolean) { - if (isActive) { - focusRequester.requestFocus() - } else { - focusManager.clearFocus(true) - if (shouldClearTextOnClearFocus) { - searchQueryTextState.clearText() - } + fun showKeyboard() { + keyboardController?.show() + } + + fun clearSearchState() { + if (shouldClearTextOnClearFocus) { + searchQueryTextState.clearText() } } - LaunchedEffect(isSearchActive) { - setActive(isSearchActive) + fun closeSearchInput() { + keyboardController?.hide() + clearSearchState() + onCloseSearchClicked?.invoke() ?: onActiveChanged(false) + } + + fun handleActiveSearchInputKey(event: KeyEvent): Boolean { + return when { + event.type == KeyEventType.KeyDown && event.key == Key.Tab && !event.isShiftPressed -> + focusManager.moveFocus(FocusDirection.Down) + + else -> false + } } + SearchFocusEffect( + isSearchActive = isSearchActive, + focusRequester = focusRequester, + onShowKeyboard = ::showKeyboard, + onClearSearchState = ::clearSearchState + ) + val placeholderAlignment by animateHorizontalAlignmentAsState( targetAlignment = if (isSearchActive) Alignment.CenterStart else Alignment.Center ) @@ -107,55 +143,231 @@ fun SearchTopBar( .fillMaxWidth() .background(MaterialTheme.wireColorScheme.background) ) { - SearchBarInput( - placeholderText = searchBarHint, - semanticDescription = searchBarDescription, - textState = searchQueryTextState, - isLoading = isLoading, - textFieldState = textFieldState, - leadingIcon = { - AnimatedContent(!isSearchActive && !keepBackButtonVisible, label = "") { showSearchIcon -> - if (showSearchIcon) { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier.size(dimensions().buttonCircleMinSize) - ) { - Icon( - painter = painterResource(R.drawable.ic_search), - contentDescription = null, - tint = MaterialTheme.wireColorScheme.onBackground, - ) - } - } else { - IconButton( - onClick = { onCloseSearchClicked?.invoke() ?: setActive(false) }, - modifier = Modifier.size(dimensions().buttonCircleMinSize) - ) { - Icon( - painter = painterResource(R.drawable.ic_arrow_back), - contentDescription = backIconContentDescription, - tint = MaterialTheme.wireColorScheme.onBackground, - ) - } - } - } - }, - placeholderTextStyle = LocalTextStyle.current.copy( - textAlign = if (!isSearchActive) TextAlign.Center else TextAlign.Start - ), - placeholderAlignment = placeholderAlignment, - textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.Start), - interactionSource = interactionSource, - onTap = onTap, - modifier = Modifier - .padding(dimensions().spacing8x) - .focusable(enabled = true) - .focusRequester(focusRequester) - .onFocusEvent { - onActiveChanged(it.isFocused) + if (isSearchActive) { + ActiveSearchBarInput( + placeholderText = searchBarHint, + semanticDescription = searchBarDescription, + textState = searchQueryTextState, + isLoading = isLoading, + textFieldState = textFieldState, + placeholderAlignment = placeholderAlignment, + interactionSource = interactionSource, + focusRequester = focusRequester, + backButtonFocusRequester = backButtonFocusRequester, + backIconContentDescription = resolvedBackIconContentDescription, + onCloseSearchInput = ::closeSearchInput, + onActiveChanged = onActiveChanged, + onInputKeyEvent = ::handleActiveSearchInputKey + ) + } else { + InactiveSearchBarInput( + placeholderText = searchBarHint, + semanticDescription = searchBarDescription, + textState = searchQueryTextState, + isLoading = isLoading, + textFieldState = textFieldState, + placeholderAlignment = placeholderAlignment, + interactionSource = interactionSource, + focusRequester = focusRequester, + keepBackButtonVisible = keepBackButtonVisible, + backIconContentDescription = resolvedBackIconContentDescription, + onCloseSearchInput = ::closeSearchInput, + onTap = onTap, + onActiveChanged = onActiveChanged, + onShowKeyboard = ::showKeyboard + ) + } + bottomContent() + } +} + +private fun resolveFocusRequester( + externalFocusRequester: FocusRequester?, + localFocusRequester: FocusRequester, +): FocusRequester = externalFocusRequester ?: localFocusRequester + +@Composable +private fun resolveBackIconContentDescription(backIconContentDescription: String?): String = + backIconContentDescription ?: stringResource(R.string.content_description_back_button) + +@Composable +private fun SearchFocusEffect( + isSearchActive: Boolean, + focusRequester: FocusRequester, + onShowKeyboard: () -> Unit, + onClearSearchState: () -> Unit, +) { + LaunchedEffect(isSearchActive) { + if (isSearchActive) { + withFrameNanos { } + focusRequester.requestFocus() + onShowKeyboard() + } else { + onClearSearchState() + } + } +} + +@Composable +private fun ActiveSearchBarInput( + placeholderText: String, + semanticDescription: String?, + textState: TextFieldState, + isLoading: Boolean, + textFieldState: WireTextFieldState, + placeholderAlignment: Alignment.Horizontal, + interactionSource: MutableInteractionSource, + focusRequester: FocusRequester, + backButtonFocusRequester: FocusRequester, + backIconContentDescription: String, + onCloseSearchInput: () -> Unit, + onActiveChanged: (Boolean) -> Unit, + onInputKeyEvent: (KeyEvent) -> Boolean, +) { + SearchBarInput( + placeholderText = placeholderText, + semanticDescription = semanticDescription, + textState = textState, + isLoading = isLoading, + textFieldState = textFieldState, + leadingIcon = { + SearchBackButton( + focusRequester = backButtonFocusRequester, + nextFocusRequester = focusRequester, + contentDescription = backIconContentDescription, + onClick = onCloseSearchInput + ) + }, + placeholderTextStyle = LocalTextStyle.current.copy(textAlign = TextAlign.Start), + placeholderAlignment = placeholderAlignment, + textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.Start), + interactionSource = interactionSource, + inputEnabled = true, + onTap = null, + modifier = Modifier + .onEscapeOrBackKey( + enabled = true, + onKeyPressed = onCloseSearchInput + ) + .padding(dimensions().spacing8x), + inputModifier = Modifier + .focusRequester(focusRequester) + .focusProperties { + previous = backButtonFocusRequester + } + .onEscapeOrBackKey( + enabled = true, + onKeyPressed = onCloseSearchInput + ) + .onPreviewKeyEvent(onInputKeyEvent) + .onFocusEvent { + onActiveChanged(it.isFocused) + } + ) +} + +@Composable +private fun InactiveSearchBarInput( + placeholderText: String, + semanticDescription: String?, + textState: TextFieldState, + isLoading: Boolean, + textFieldState: WireTextFieldState, + placeholderAlignment: Alignment.Horizontal, + interactionSource: MutableInteractionSource, + focusRequester: FocusRequester, + keepBackButtonVisible: Boolean, + backIconContentDescription: String, + onCloseSearchInput: () -> Unit, + onTap: (() -> Unit)?, + onActiveChanged: (Boolean) -> Unit, + onShowKeyboard: () -> Unit, +) { + fun activateSearch() { + onTap?.invoke() + onActiveChanged(true) + onShowKeyboard() + } + + SearchBarInput( + placeholderText = placeholderText, + semanticDescription = semanticDescription, + textState = textState, + isLoading = isLoading, + textFieldState = textFieldState, + leadingIcon = { + if (keepBackButtonVisible) { + SearchBackButton( + focusRequester = null, + nextFocusRequester = null, + contentDescription = backIconContentDescription, + onClick = onCloseSearchInput + ) + } else { + SearchIcon() + } + }, + placeholderTextStyle = LocalTextStyle.current.copy(textAlign = TextAlign.Center), + placeholderAlignment = placeholderAlignment, + textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.Start), + interactionSource = interactionSource, + inputEnabled = true, + onTap = ::activateSearch, + modifier = Modifier + .padding(dimensions().spacing8x) + .focusRequester(focusRequester) + .onFocusChanged { + if (it.isFocused) { + activateSearch() } + } + .semantics { + semanticDescription?.let { contentDescription = it } + } + .focusable(), + inputModifier = Modifier.onFocusEvent { + if (it.isFocused) { + activateSearch() + } + } + ) +} + +@Composable +private fun SearchBackButton( + focusRequester: FocusRequester?, + nextFocusRequester: FocusRequester?, + contentDescription: String, + onClick: () -> Unit, +) { + IconButton( + onClick = onClick, + modifier = Modifier + .size(dimensions().buttonCircleMinSize) + .then(focusRequester?.let { Modifier.focusRequester(it) } ?: Modifier) + .focusProperties { + nextFocusRequester?.let { next = it } + } + ) { + Icon( + painter = painterResource(R.drawable.ic_arrow_back), + contentDescription = contentDescription, + tint = MaterialTheme.wireColorScheme.onBackground, + ) + } +} + +@Composable +private fun SearchIcon() { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.size(dimensions().buttonCircleMinSize) + ) { + Icon( + painter = painterResource(R.drawable.ic_search), + contentDescription = null, + tint = MaterialTheme.wireColorScheme.onBackground, ) - bottomContent() } } diff --git a/kalium b/kalium index a8197f63a44..324cba76993 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit a8197f63a4448c684ae60d3b4f895b3aac3b2910 +Subproject commit 324cba7699382abeca2d6ff389221f631efcbed3