diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 624b4bc38f5..5c3b60fc3f5 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -113,7 +113,7 @@ android:launchMode="singleTask" android:screenOrientation="portrait" android:theme="@style/AppTheme.SplashScreen" - android:windowSoftInputMode="adjustResize|stateHidden"> + android:windowSoftInputMode="adjustNothing|stateHidden"> diff --git a/app/src/main/kotlin/com/wire/android/ui/common/AttachmentButton.kt b/app/src/main/kotlin/com/wire/android/ui/common/AttachmentButton.kt index 7b5dc1abfe8..6c9f5d403c2 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/AttachmentButton.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/AttachmentButton.kt @@ -48,13 +48,7 @@ import com.wire.android.ui.theme.wireDimensions import com.wire.android.ui.theme.wireTypography @Composable -fun AttachmentButton( - text: String = "", - @DrawableRes icon: Int, - labelStyle: TextStyle, - modifier: Modifier = Modifier, - onClick: () -> Unit -) { +fun AttachmentButton(@DrawableRes icon: Int, labelStyle: TextStyle, modifier: Modifier = Modifier, text: String = "", onClick: () -> Unit) { Column( modifier = modifier .padding(dimensions().spacing4x) @@ -96,5 +90,10 @@ fun AttachmentButton( @Preview(showBackground = true) @Composable fun PreviewAttachmentButton() { - AttachmentButton("Share Location", R.drawable.ic_location, MaterialTheme.wireTypography.button03) { } + AttachmentButton( + icon = R.drawable.ic_location, + labelStyle = MaterialTheme.wireTypography.button03, + modifier = Modifier, + text = "Share Location" + ) { } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt index bca4060390e..ece0d8bcc1b 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt @@ -31,6 +31,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -93,6 +94,7 @@ import com.wire.android.navigation.WireDestination import com.wire.android.ui.LocalActivity import com.wire.android.ui.calling.getOutgoingCallIntent import com.wire.android.ui.calling.ongoing.getOngoingCallIntent +import com.wire.android.ui.common.bottomsheet.rememberWireModalSheetState import com.wire.android.ui.common.colorsScheme import com.wire.android.ui.common.dialogs.ConfirmSendingPingDialog import com.wire.android.ui.common.dialogs.InvalidLinkDialog @@ -146,11 +148,11 @@ import com.wire.android.ui.home.conversations.sendmessage.SendMessageViewModel import com.wire.android.ui.home.gallery.MediaGalleryActionType import com.wire.android.ui.home.gallery.MediaGalleryNavBackArgs import com.wire.android.ui.home.messagecomposer.MessageComposer +import com.wire.android.ui.home.messagecomposer.location.LocationPickerComponent import com.wire.android.ui.home.messagecomposer.model.ComposableMessageBundle import com.wire.android.ui.home.messagecomposer.model.MessageBundle import com.wire.android.ui.home.messagecomposer.model.MessageComposition import com.wire.android.ui.home.messagecomposer.model.Ping -import com.wire.android.ui.home.messagecomposer.state.AdditionalOptionSelectItem import com.wire.android.ui.home.messagecomposer.state.MessageComposerStateHolder import com.wire.android.ui.home.messagecomposer.state.rememberMessageComposerStateHolder import com.wire.android.ui.legalhold.dialog.subject.LegalHoldSubjectMessageDialog @@ -228,7 +230,6 @@ fun ConversationScreen( val uriHandler = LocalUriHandler.current val resources = LocalContext.current.resources val showDialog = remember { mutableStateOf(ConversationScreenDialogType.NONE) } - val conversationScreenState = rememberConversationScreenState() val messageComposerViewState = messageComposerViewModel.messageComposerViewState val messageComposerStateHolder = rememberMessageComposerStateHolder( messageComposerViewState = messageComposerViewState, @@ -238,6 +239,20 @@ fun ConversationScreen( onTypingEvent = messageComposerViewModel::sendTypingEvent, onClearMentionSearchResult = messageComposerViewModel::clearMentionSearchResult ) + val conversationScreenState = rememberConversationScreenState( + selfDeletingSheetState = rememberWireModalSheetState(onDismissAction = { + messageComposerStateHolder.messageCompositionInputStateHolder.setFocused() + }), + locationSheetState = rememberWireModalSheetState( + onDismissAction = { + messageComposerStateHolder.messageCompositionInputStateHolder.setFocused() + } + ), + editSheetState = rememberWireModalSheetState(onDismissAction = { + messageComposerStateHolder.messageCompositionInputStateHolder.setFocused() + }), + ) + val permissionPermanentlyDeniedDialogState = rememberVisibilityState() @@ -250,10 +265,7 @@ fun ConversationScreen( LaunchedEffect(conversationScreenState.isAnySheetVisible) { with(messageComposerStateHolder) { if (conversationScreenState.isAnySheetVisible) { - messageCompositionInputStateHolder.clearFocus() - } else if (additionalOptionStateHolder.selectedOption == AdditionalOptionSelectItem.SelfDeleting) { - messageCompositionInputStateHolder.requestFocus() - additionalOptionStateHolder.unselectAdditionalOptionsMenu() + messageCompositionInputStateHolder.showAttachments(false) } } } @@ -398,6 +410,7 @@ fun ConversationScreen( ConversationScreen( bannerMessage = conversationBannerViewModel.bannerState, messageComposerViewState = messageComposerViewState.value, + bottomSheetVisible = conversationScreenState.isAnySheetVisible, conversationCallViewState = conversationListCallViewModel.conversationCallViewState, conversationInfoViewState = conversationInfoViewModel.conversationInfoViewState, conversationMessagesViewState = conversationMessagesViewModel.conversationViewState, @@ -757,6 +770,7 @@ private fun ConversationScreen( conversationCallViewState: ConversationCallViewState, conversationInfoViewState: ConversationInfoViewState, conversationMessagesViewState: ConversationMessagesViewState, + bottomSheetVisible: Boolean, onOpenProfile: (String) -> Unit, onMessageDetailsClick: (messageId: String, isSelfMessage: Boolean) -> Unit, onSendMessage: (MessageBundle) -> Unit, @@ -795,104 +809,127 @@ private fun ConversationScreen( ) { val context = LocalContext.current val snackbarHostState = LocalSnackbarHostState.current - // only here we will use normal Scaffold because of specific behaviour of message composer - Scaffold( - topBar = { - Column { - ConversationScreenTopAppBar( - conversationInfoViewState = conversationInfoViewState, - onBackButtonClick = onBackButtonClick, - onDropDownClick = onDropDownClick, - isDropDownEnabled = conversationInfoViewState.hasUserPermissionToEdit, - onSearchButtonClick = { }, - onPhoneButtonClick = onStartCall, - hasOngoingCall = conversationCallViewState.hasOngoingCall, - onJoinCallButtonClick = onJoinCall, - onAudioPermissionPermanentlyDenied = { - onPermissionPermanentlyDenied(ConversationActionPermissionType.CallAudio) - }, - isInteractionEnabled = messageComposerViewState.interactionAvailability == InteractionAvailability.ENABLED + Box(modifier = Modifier) { + // only here we will use normal Scaffold because of specific behaviour of message composer + Scaffold( + topBar = { + Column { + ConversationScreenTopAppBar( + conversationInfoViewState = conversationInfoViewState, + onBackButtonClick = onBackButtonClick, + onDropDownClick = onDropDownClick, + isDropDownEnabled = conversationInfoViewState.hasUserPermissionToEdit, + onSearchButtonClick = { }, + onPhoneButtonClick = onStartCall, + hasOngoingCall = conversationCallViewState.hasOngoingCall, + onJoinCallButtonClick = onJoinCall, + onAudioPermissionPermanentlyDenied = { + onPermissionPermanentlyDenied(ConversationActionPermissionType.CallAudio) + }, + isInteractionEnabled = messageComposerViewState.interactionAvailability == InteractionAvailability.ENABLED + ) + ConversationBanner(bannerMessage) + } + }, + snackbarHost = { + SnackbarHost( + hostState = snackbarHostState, + snackbar = { data -> + SwipeableSnackbar( + hostState = snackbarHostState, + data = data, + onDismiss = { data.dismiss() } + ) + } ) - ConversationBanner(bannerMessage) - } - }, - snackbarHost = { - SnackbarHost( - hostState = snackbarHostState, - snackbar = { data -> - SwipeableSnackbar( - hostState = snackbarHostState, - data = data, - onDismiss = { data.dismiss() } + }, + content = { internalPadding -> + Box( + modifier = Modifier + .padding(internalPadding) + .consumeWindowInsets(internalPadding) + ) { + ConversationScreenContent( + conversationId = conversationInfoViewState.conversationId, + bottomSheetVisible = bottomSheetVisible, + audioMessagesState = conversationMessagesViewState.audioMessagesState, + assetStatuses = conversationMessagesViewState.assetStatuses, + lastUnreadMessageInstant = conversationMessagesViewState.firstUnreadInstant, + unreadEventCount = conversationMessagesViewState.firstuUnreadEventIndex, + conversationDetailsData = conversationInfoViewState.conversationDetailsData, + selectedMessageId = conversationMessagesViewState.searchedMessageId, + messageComposerStateHolder = messageComposerStateHolder, + messages = conversationMessagesViewState.messages, + onSendMessage = onSendMessage, + onPingOptionClicked = onPingOptionClicked, + onImagesPicked = onImagesPicked, + onAssetItemClicked = onAssetItemClicked, + onAudioItemClicked = onAudioClick, + onChangeAudioPosition = onChangeAudioPosition, + onImageFullScreenMode = onImageFullScreenMode, + onReactionClicked = onReactionClick, + onResetSessionClicked = onResetSessionClick, + onOpenProfile = onOpenProfile, + onUpdateConversationReadDate = onUpdateConversationReadDate, + onShowEditingOptions = conversationScreenState::showEditContextMenu, + onSwipedToReply = messageComposerStateHolder::toReply, + onSelfDeletingMessageRead = onSelfDeletingMessageRead, + onFailedMessageCancelClicked = remember { { onDeleteMessage(it, false) } }, + onFailedMessageRetryClicked = onFailedMessageRetryClicked, + onChangeSelfDeletionClicked = conversationScreenState::showSelfDeletionContextMenu, + onLocationClicked = conversationScreenState::showLocationSheet, + onClearMentionSearchResult = onClearMentionSearchResult, + onPermissionPermanentlyDenied = onPermissionPermanentlyDenied, + tempWritableImageUri = tempWritableImageUri, + tempWritableVideoUri = tempWritableVideoUri, + onLinkClick = onLinkClick, + onNavigateToReplyOriginalMessage = onNavigateToReplyOriginalMessage, + currentTimeInMillisFlow = currentTimeInMillisFlow, + openDrawingCanvas = openDrawingCanvas ) } - ) - }, - content = { internalPadding -> - Box(modifier = Modifier.padding(internalPadding)) { - ConversationScreenContent( - conversationId = conversationInfoViewState.conversationId, - audioMessagesState = conversationMessagesViewState.audioMessagesState, - assetStatuses = conversationMessagesViewState.assetStatuses, - lastUnreadMessageInstant = conversationMessagesViewState.firstUnreadInstant, - unreadEventCount = conversationMessagesViewState.firstuUnreadEventIndex, - conversationDetailsData = conversationInfoViewState.conversationDetailsData, - selectedMessageId = conversationMessagesViewState.searchedMessageId, - messageComposerStateHolder = messageComposerStateHolder, - messages = conversationMessagesViewState.messages, - onSendMessage = onSendMessage, - onPingOptionClicked = onPingOptionClicked, - onImagesPicked = onImagesPicked, - onAssetItemClicked = onAssetItemClicked, - onAudioItemClicked = onAudioClick, - onChangeAudioPosition = onChangeAudioPosition, - onImageFullScreenMode = onImageFullScreenMode, - onReactionClicked = onReactionClick, - onResetSessionClicked = onResetSessionClick, - onOpenProfile = onOpenProfile, - onUpdateConversationReadDate = onUpdateConversationReadDate, - onShowEditingOptions = conversationScreenState::showEditContextMenu, - onSwipedToReply = messageComposerStateHolder::toReply, - onSelfDeletingMessageRead = onSelfDeletingMessageRead, - onFailedMessageCancelClicked = remember { { onDeleteMessage(it, false) } }, - onFailedMessageRetryClicked = onFailedMessageRetryClicked, - onChangeSelfDeletionClicked = conversationScreenState::showSelfDeletionContextMenu, - onClearMentionSearchResult = onClearMentionSearchResult, - onPermissionPermanentlyDenied = onPermissionPermanentlyDenied, - tempWritableImageUri = tempWritableImageUri, - tempWritableVideoUri = tempWritableVideoUri, - onLinkClick = onLinkClick, - onNavigateToReplyOriginalMessage = onNavigateToReplyOriginalMessage, - currentTimeInMillisFlow = currentTimeInMillisFlow, - openDrawingCanvas = openDrawingCanvas, + } + ) + + MessageOptionsModalSheetLayout( + sheetState = conversationScreenState.editSheetState, + onCopyClick = conversationScreenState::copyMessage, + onDeleteClick = onDeleteMessage, + onReactionClick = onReactionClick, + onDetailsClick = onMessageDetailsClick, + onReplyClick = messageComposerStateHolder::toReply, + onEditClick = messageComposerStateHolder::toEdit, + onShareAssetClick = { shareAsset(context, it) }, + onDownloadAssetClick = onDownloadAssetClick, + onOpenAssetClick = onOpenAssetClick, + ) + + SelfDeletionOptionsModalSheetLayout( + sheetState = conversationScreenState.selfDeletingSheetState, + onNewSelfDeletingMessagesStatus = onNewSelfDeletingMessagesStatus + ) + LocationPickerComponent( + sheetState = conversationScreenState.locationSheetState, + onLocationPicked = { + onSendMessage( + ComposableMessageBundle.LocationBundle( + conversationInfoViewState.conversationId, + it.getFormattedAddress(), + it.location + ) ) } - } - ) - SelfDeletionOptionsModalSheetLayout( - sheetState = conversationScreenState.selfDeletingSheetState, - onNewSelfDeletingMessagesStatus = onNewSelfDeletingMessagesStatus - ) - MessageOptionsModalSheetLayout( - sheetState = conversationScreenState.editSheetState, - onCopyClick = conversationScreenState::copyMessage, - onDeleteClick = onDeleteMessage, - onReactionClick = onReactionClick, - onDetailsClick = onMessageDetailsClick, - onReplyClick = messageComposerStateHolder::toReply, - onEditClick = messageComposerStateHolder::toEdit, - onShareAssetClick = { shareAsset(context, it) }, - onDownloadAssetClick = onDownloadAssetClick, - onOpenAssetClick = onOpenAssetClick, - ) + ) - SnackBarMessage(composerMessages, conversationMessages) + SnackBarMessage(composerMessages, conversationMessages) + } } @Suppress("LongParameterList") @Composable private fun ConversationScreenContent( conversationId: ConversationId, + bottomSheetVisible: Boolean, lastUnreadMessageInstant: Instant?, unreadEventCount: Int, audioMessagesState: PersistentMap, @@ -919,6 +956,7 @@ private fun ConversationScreenContent( onFailedMessageCancelClicked: (String) -> Unit, onChangeSelfDeletionClicked: (SelfDeletionTimer) -> Unit, onClearMentionSearchResult: () -> Unit, + onLocationClicked: () -> Unit, onPermissionPermanentlyDenied: (type: ConversationActionPermissionType) -> Unit, tempWritableImageUri: Uri?, tempWritableVideoUri: Uri?, @@ -935,6 +973,7 @@ private fun ConversationScreenContent( MessageComposer( conversationId = conversationId, + bottomSheetVisible = bottomSheetVisible, messageComposerStateHolder = messageComposerStateHolder, messageListContent = { MessageList( @@ -965,6 +1004,7 @@ private fun ConversationScreenContent( ) }, onChangeSelfDeletionClicked = onChangeSelfDeletionClicked, + onLocationClicked = onLocationClicked, onClearMentionSearchResult = onClearMentionSearchResult, onSendMessageBundle = onSendMessage, onPingOptionClicked = onPingOptionClicked, @@ -1341,6 +1381,7 @@ fun PreviewConversationScreen() = WireTheme { ) ConversationScreen( bannerMessage = null, + bottomSheetVisible = false, messageComposerViewState = messageComposerViewState.value, conversationCallViewState = ConversationCallViewState(), conversationInfoViewState = ConversationInfoViewState( diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreenState.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreenState.kt index cd2cb163f3f..f13fd12e39e 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreenState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreenState.kt @@ -30,6 +30,7 @@ import androidx.compose.ui.text.AnnotatedString import com.wire.android.R import com.wire.android.ui.common.bottomsheet.WireModalSheetState import com.wire.android.ui.common.bottomsheet.rememberWireModalSheetState +import com.wire.android.ui.common.bottomsheet.show import com.wire.android.ui.common.snackbar.LocalSnackbarHostState import com.wire.android.ui.home.conversations.model.UIMessage import com.wire.kalium.logic.data.message.SelfDeletionTimer @@ -40,12 +41,12 @@ import kotlinx.coroutines.launch fun rememberConversationScreenState( editSheetState: WireModalSheetState = rememberWireModalSheetState(), selfDeletingSheetState: WireModalSheetState = rememberWireModalSheetState(), + locationSheetState: WireModalSheetState = rememberWireModalSheetState(), coroutineScope: CoroutineScope = rememberCoroutineScope() ): ConversationScreenState { val context = LocalContext.current val clipboardManager = LocalClipboardManager.current val snackBarHostState = LocalSnackbarHostState.current - return remember { ConversationScreenState( context = context, @@ -53,6 +54,7 @@ fun rememberConversationScreenState( snackBarHostState = snackBarHostState, editSheetState = editSheetState, selfDeletingSheetState = selfDeletingSheetState, + locationSheetState = locationSheetState, coroutineScope = coroutineScope ) } @@ -65,10 +67,11 @@ class ConversationScreenState( val snackBarHostState: SnackbarHostState, val editSheetState: WireModalSheetState, val selfDeletingSheetState: WireModalSheetState, + val locationSheetState: WireModalSheetState, val coroutineScope: CoroutineScope ) { fun showEditContextMenu(message: UIMessage.Regular) { - editSheetState.show(message) + editSheetState.show(message, hideKeyboard = true) } fun copyMessage(text: String) { @@ -79,9 +82,13 @@ class ConversationScreenState( } fun showSelfDeletionContextMenu(currentlySelected: SelfDeletionTimer) { - selfDeletingSheetState.show(currentlySelected) + selfDeletingSheetState.show(currentlySelected, hideKeyboard = true) + } + + fun showLocationSheet() { + locationSheetState.show(hideKeyboard = true) } val isAnySheetVisible: Boolean - get() = editSheetState.isVisible || selfDeletingSheetState.isVisible + get() = editSheetState.isVisible || selfDeletingSheetState.isVisible || locationSheetState.isVisible } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/edit/MessageOptionsModalSheetLayout.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/edit/MessageOptionsModalSheetLayout.kt index 9b1df22408a..8527d63d8d3 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/edit/MessageOptionsModalSheetLayout.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/edit/MessageOptionsModalSheetLayout.kt @@ -47,7 +47,7 @@ fun MessageOptionsModalSheetLayout( onEditClick: (messageId: String, messageBody: String, mentions: List) -> Unit, onShareAssetClick: (messageId: String) -> Unit, onDownloadAssetClick: (messageId: String) -> Unit, - onOpenAssetClick: (messageId: String) -> Unit + onOpenAssetClick: (messageId: String) -> Unit, ) { val context = LocalContext.current WireModalSheetLayout( diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/messages/SearchConversationMessagesResultsScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/messages/SearchConversationMessagesResultsScreen.kt index 4f6fa1df630..5b0d2cb1ee3 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/messages/SearchConversationMessagesResultsScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/messages/SearchConversationMessagesResultsScreen.kt @@ -57,6 +57,7 @@ fun SearchConversationMessagesResultsScreen( message = message, conversationDetailsData = ConversationDetailsData.None(null), searchQuery = searchQuery, + audioState = null, onLongClicked = { }, onAssetMessageClicked = { }, onAudioClick = { }, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/selfdeletion/SelfDeletionOptionsModalSheetLayout.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/selfdeletion/SelfDeletionOptionsModalSheetLayout.kt index a4f0d85277d..2114a9c1041 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/selfdeletion/SelfDeletionOptionsModalSheetLayout.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/selfdeletion/SelfDeletionOptionsModalSheetLayout.kt @@ -19,6 +19,7 @@ package com.wire.android.ui.home.conversations.selfdeletion import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import com.wire.android.R import com.wire.android.ui.common.bottomsheet.MenuModalSheetHeader @@ -36,9 +37,11 @@ import com.wire.kalium.logic.data.message.SelfDeletionTimer fun SelfDeletionOptionsModalSheetLayout( sheetState: WireModalSheetState, onNewSelfDeletingMessagesStatus: (SelfDeletionTimer) -> Unit, + modifier: Modifier = Modifier ) { WireModalSheetLayout( sheetState = sheetState, + modifier = modifier, sheetContent = { currentlySelected -> WireMenuModalSheetContent( header = MenuModalSheetHeader.Visible(title = stringResource(R.string.automatically_delete_message_after)), 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 e8b690d3ef6..ae3e6efacfc 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 @@ -30,8 +30,6 @@ import androidx.compose.ui.Modifier import com.wire.android.ui.common.colorsScheme import com.wire.android.ui.home.conversations.ConversationActionPermissionType import com.wire.android.ui.home.conversations.model.UriAsset -import com.wire.android.ui.home.messagecomposer.location.GeoLocatedAddress -import com.wire.android.ui.home.messagecomposer.location.LocationPickerComponent import com.wire.android.ui.home.messagecomposer.recordaudio.RecordAudioComponent import com.wire.android.ui.home.messagecomposer.state.AdditionalOptionMenuState import com.wire.android.ui.home.messagecomposer.state.AdditionalOptionSelectItem @@ -46,6 +44,7 @@ fun AdditionalOptionsMenu( conversationId: ConversationId, additionalOptionsState: AdditionalOptionMenuState, selectedOption: AdditionalOptionSelectItem, + attachmentsVisible: Boolean, isEditing: Boolean, isMentionActive: Boolean, onAdditionalOptionsMenuClicked: () -> Unit, @@ -65,6 +64,7 @@ fun AdditionalOptionsMenu( AttachmentAndAdditionalOptionsMenuItems( conversationId = conversationId, selectedOption = selectedOption, + attachmentsVisible = attachmentsVisible, isEditing = isEditing, isMentionActive = isMentionActive, onMentionButtonClicked = onMentionButtonClicked, @@ -85,8 +85,6 @@ fun AdditionalOptionsMenu( onCloseRichTextEditingButtonClicked = onCloseRichEditingButtonClicked ) } - - AdditionalOptionMenuState.Hidden -> {} } } } @@ -94,6 +92,7 @@ fun AdditionalOptionsMenu( @Composable fun AdditionalOptionSubMenu( isFileSharingEnabled: Boolean, + optionsVisible: Boolean, onPermissionPermanentlyDenied: (type: ConversationActionPermissionType) -> Unit, onLocationPickerClicked: () -> Unit, onCloseAdditionalAttachment: () -> Unit, @@ -102,13 +101,13 @@ fun AdditionalOptionSubMenu( onImagesPicked: (List) -> Unit, onAttachmentPicked: (UriAsset) -> Unit, onAudioRecorded: (UriAsset) -> Unit, - onLocationPicked: (GeoLocatedAddress) -> Unit, tempWritableImageUri: Uri?, tempWritableVideoUri: Uri?, modifier: Modifier = Modifier, ) { - Box(modifier = modifier) { AttachmentOptionsComponent( + modifier = modifier, + optionsVisible = optionsVisible, onImagesPicked = onImagesPicked, onAttachmentPicked = onAttachmentPicked, tempWritableImageUri = tempWritableImageUri, @@ -119,9 +118,7 @@ fun AdditionalOptionSubMenu( onPermissionPermanentlyDenied = onPermissionPermanentlyDenied, ) when (additionalOptionsState) { - AdditionalOptionSubMenuState.AttachFile -> { - /* DO NOTHING, ALREADY DISPLAYED AS PARENT */ - } + AdditionalOptionSubMenuState.Default -> {} AdditionalOptionSubMenuState.RecordAudio -> { RecordAudioComponent( @@ -129,19 +126,7 @@ fun AdditionalOptionSubMenu( onCloseRecordAudio = onCloseAdditionalAttachment ) } - - AdditionalOptionSubMenuState.Location -> { - LocationPickerComponent( - onLocationPicked = onLocationPicked, - onLocationClosed = onCloseAdditionalAttachment - ) - } - // non functional for now - AdditionalOptionSubMenuState.AttachImage -> {} - AdditionalOptionSubMenuState.Emoji -> {} - AdditionalOptionSubMenuState.Gif -> {} } - } } @Composable @@ -149,6 +134,7 @@ fun AttachmentAndAdditionalOptionsMenuItems( conversationId: ConversationId, isEditing: Boolean, selectedOption: AdditionalOptionSelectItem, + attachmentsVisible: Boolean, isMentionActive: Boolean, onMentionButtonClicked: () -> Unit, onSelfDeletionOptionButtonClicked: (SelfDeletionTimer) -> Unit, @@ -164,6 +150,7 @@ fun AttachmentAndAdditionalOptionsMenuItems( MessageComposeActions( conversationId = conversationId, isEditing = isEditing, + attachmentsVisible = attachmentsVisible, selectedOption = selectedOption, isMentionActive = isMentionActive, onMentionButtonClicked = onMentionButtonClicked, 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 8ae3af417ab..a9ea0fb8e8b 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 @@ -21,22 +21,27 @@ package com.wire.android.ui.home.messagecomposer import android.net.Uri import androidx.annotation.DrawableRes import androidx.annotation.StringRes +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.keyframes import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.material.Surface import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +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.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.rememberTextMeasurer @@ -47,6 +52,7 @@ import com.wire.android.ui.common.AttachmentButton import com.wire.android.ui.common.dimensions import com.wire.android.ui.home.conversations.ConversationActionPermissionType import com.wire.android.ui.home.conversations.model.UriAsset +import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireTypography import com.wire.android.util.debug.LocalFeatureVisibilityFlags import com.wire.android.util.permission.FileType @@ -55,10 +61,10 @@ import com.wire.android.util.permission.rememberCaptureVideoFlow import com.wire.android.util.permission.rememberChooseMultipleFilesFlow import com.wire.android.util.permission.rememberChooseSingleFileFlow import com.wire.android.util.permission.rememberTakePictureFlow -import com.wire.android.util.ui.KeyboardHeight @Composable fun AttachmentOptionsComponent( + optionsVisible: Boolean, onImagesPicked: (List) -> Unit, onAttachmentPicked: (UriAsset) -> Unit, onRecordAudioMessageClicked: () -> Unit, @@ -115,6 +121,7 @@ fun AttachmentOptionsComponent( } } val (columns, contentPadding) = params + val numberOfColumns = (fullWidth / minColumnWidth).toInt() LazyVerticalGrid( columns = columns, @@ -123,9 +130,42 @@ fun AttachmentOptionsComponent( verticalArrangement = Arrangement.SpaceEvenly, horizontalArrangement = Arrangement.Center ) { - visibleAttachmentOptions.forEach { option -> + visibleAttachmentOptions.forEachIndexed { index, option -> if (option.shouldShow) { - item { AttachmentButton(stringResource(option.text), option.icon, labelStyle) { option.onClick() } } + item { + val column = index % numberOfColumns + val reverseIndex = visibleAttachmentOptions.size - 1 - index + + var startAnimation by remember { mutableStateOf(false) } + + val animatedScale by animateFloatAsState( + targetValue = if (startAnimation) 1.0f else 0.0f, + animationSpec = keyframes { + durationMillis = 150 + if (startAnimation) { + 1.2f at 50 using FastOutSlowInEasing + 1.0f at 100 using FastOutSlowInEasing + } else { + 1.0f at 0 using FastOutSlowInEasing + 0.0f at 50 using FastOutSlowInEasing + } + }, + label = "attachmentsAnimation" + ) + + LaunchedEffect(optionsVisible) { + val delayMillis = if (optionsVisible) column * 50L else reverseIndex * 25L + kotlinx.coroutines.delay(delayMillis) + startAnimation = optionsVisible + } + + AttachmentButton( + icon = option.icon, + labelStyle = labelStyle, + modifier = Modifier.scale(animatedScale), + text = stringResource(option.text) + ) { option.onClick() } + } } } } @@ -331,27 +371,30 @@ private data class AttachmentOptionItem( @Preview(showBackground = true, locale = "de") @Composable fun PreviewAttachmentComponents() { - AttachmentOptionsComponent( - onImagesPicked = {}, - onAttachmentPicked = {}, - isFileSharingEnabled = true, - tempWritableImageUri = null, - tempWritableVideoUri = null, - onRecordAudioMessageClicked = {}, - onLocationPickerClicked = {}, - onPermissionPermanentlyDenied = {}, - ) + WireTheme { + AttachmentOptionsComponent( + optionsVisible = true, + onImagesPicked = {}, + onAttachmentPicked = {}, + isFileSharingEnabled = true, + tempWritableImageUri = null, + tempWritableVideoUri = null, + onRecordAudioMessageClicked = {}, + onLocationPickerClicked = {}, + onPermissionPermanentlyDenied = {}, + ) + } } @Preview(name = "Small Screen", widthDp = 320, heightDp = 480, showBackground = true) @Composable fun PreviewAttachmentOptionsComponentSmallScreen() { - Surface { + WireTheme { Box( - modifier = Modifier.height(KeyboardHeight.default), contentAlignment = Alignment.BottomCenter ) { AttachmentOptionsComponent( + optionsVisible = true, onAttachmentPicked = {}, onImagesPicked = {}, isFileSharingEnabled = true, @@ -368,12 +411,12 @@ fun PreviewAttachmentOptionsComponentSmallScreen() { @Preview(name = "Normal Screen", widthDp = 360, heightDp = 640) @Composable fun PreviewAttachmentOptionsComponentNormalScreen() { - Surface { + WireTheme { Box( - modifier = Modifier.height(KeyboardHeight.default), contentAlignment = Alignment.BottomCenter ) { AttachmentOptionsComponent( + optionsVisible = true, onAttachmentPicked = {}, onImagesPicked = {}, isFileSharingEnabled = true, @@ -390,12 +433,12 @@ fun PreviewAttachmentOptionsComponentNormalScreen() { @Preview(name = "Tablet Screen", widthDp = 600, heightDp = 960) @Composable fun PreviewAttachmentOptionsComponentTabledScreen() { - Surface { + WireTheme { Box( - modifier = Modifier.height(KeyboardHeight.default), contentAlignment = Alignment.BottomCenter ) { AttachmentOptionsComponent( + optionsVisible = true, onAttachmentPicked = {}, onImagesPicked = {}, isFileSharingEnabled = true, 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 462545f84bb..4c0e225e224 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 @@ -19,8 +19,8 @@ package com.wire.android.ui.home.messagecomposer import android.net.Uri import androidx.activity.compose.BackHandler -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -33,9 +33,12 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.imeAnimationSource import androidx.compose.foundation.layout.imeAnimationTarget +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.isImeVisible +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -45,35 +48,45 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue 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.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupProperties import com.wire.android.ui.common.banner.SecurityClassificationBannerForConversation import com.wire.android.ui.common.bottombar.BottomNavigationBarHeight import com.wire.android.ui.common.colorsScheme +import com.wire.android.ui.common.dimensions 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.location.GeoLocatedAddress -import com.wire.android.ui.home.messagecomposer.state.AdditionalOptionSelectItem 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 import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.data.message.SelfDeletionTimer +import kotlin.math.roundToInt @OptIn(ExperimentalLayoutApi::class) @Suppress("ComplexMethod") @Composable fun EnabledMessageComposer( conversationId: ConversationId, + bottomSheetVisible: Boolean, messageComposerStateHolder: MessageComposerStateHolder, messageListContent: @Composable () -> Unit, onChangeSelfDeletionClicked: (currentlySelected: SelfDeletionTimer) -> Unit, + onLocationClicked: () -> Unit, onSendButtonClicked: () -> Unit, onImagesPicked: (List) -> Unit, onAttachmentPicked: (UriAsset) -> Unit, onAudioRecorded: (UriAsset) -> Unit, - onLocationPicked: (GeoLocatedAddress) -> Unit, onPermissionPermanentlyDenied: (type: ConversationActionPermissionType) -> Unit, onPingOptionClicked: () -> Unit, onClearMentionSearchResult: () -> Unit, @@ -86,19 +99,14 @@ fun EnabledMessageComposer( val navBarHeight = BottomNavigationBarHeight() val isImeVisible = WindowInsets.isImeVisible val offsetY = WindowInsets.ime.getBottom(density) - val isKeyboardMoving = isKeyboardMoving() val imeAnimationSource = WindowInsets.imeAnimationSource.getBottom(density) val imeAnimationTarget = WindowInsets.imeAnimationTarget.getBottom(density) + val rippleProgress = remember { Animatable(0f) } + var hideRipple by remember { mutableStateOf(true) } with(messageComposerStateHolder) { val inputStateHolder = messageCompositionInputStateHolder - LaunchedEffect(isImeVisible) { - if (!isImeVisible) { - inputStateHolder.clearFocus() - } - } - LaunchedEffect(offsetY) { with(density) { inputStateHolder.handleImeOffsetChange( @@ -110,12 +118,31 @@ fun EnabledMessageComposer( } } + LaunchedEffect(inputStateHolder.optionsVisible) { + if (inputStateHolder.optionsVisible) { + rippleProgress.snapTo(0f) + rippleProgress.animateTo( + targetValue = 1f, + animationSpec = tween(durationMillis = 400) + ) + } else { + hideRipple = true + rippleProgress.animateTo( + targetValue = 0f, + animationSpec = tween(durationMillis = 400) + ) + hideRipple = false + } + } + Surface( modifier = modifier, color = colorsScheme().messageComposerBackgroundColor ) { Column( - modifier = Modifier.fillMaxSize() + modifier = Modifier + .fillMaxSize() + .imePadding() ) { val expandOrHideMessagesModifier = if (inputStateHolder.isTextExpanded) { @@ -163,143 +190,190 @@ fun EnabledMessageComposer( ) } - if (additionalOptionStateHolder.additionalOptionsSubMenuState != AdditionalOptionSubMenuState.RecordAudio) { - Box(fillRemainingSpaceOrWrapContent, contentAlignment = Alignment.BottomCenter) { - var currentSelectedLineIndex by remember { mutableStateOf(0) } - var cursorCoordinateY by remember { mutableStateOf(0F) } + Box(fillRemainingSpaceOrWrapContent, contentAlignment = Alignment.BottomCenter) { + var currentSelectedLineIndex by remember { mutableStateOf(0) } + var cursorCoordinateY by remember { mutableStateOf(0F) } - ActiveMessageComposerInput( - conversationId = conversationId, - messageComposition = messageComposition.value, - messageTextState = inputStateHolder.messageTextState, - isTextExpanded = inputStateHolder.isTextExpanded, - inputType = messageCompositionInputStateHolder.inputType, - inputFocused = messageCompositionInputStateHolder.inputFocused, - onInputFocusedChanged = ::onInputFocusedChanged, - onToggleInputSize = messageCompositionInputStateHolder::toggleInputSize, - onTextCollapse = messageCompositionInputStateHolder::collapseText, - onCancelReply = messageCompositionHolder::clearReply, - onCancelEdit = ::cancelEdit, - onChangeSelfDeletionClicked = onChangeSelfDeletionClicked, - onSendButtonClicked = onSendButtonClicked, - onEditButtonClicked = { - onSendButtonClicked() - messageCompositionInputStateHolder.toComposing() - }, - onLineBottomYCoordinateChanged = { yCoordinate -> - cursorCoordinateY = yCoordinate - }, - onSelectedLineIndexChanged = { index -> - currentSelectedLineIndex = index - }, - showOptions = inputStateHolder.optionsVisible, - onPlusClick = ::showAdditionalOptionsMenu, - modifier = fillRemainingSpaceOrWrapContent, - ) + ActiveMessageComposerInput( + conversationId = conversationId, + messageComposition = messageComposition.value, + messageTextState = inputStateHolder.messageTextState, + isTextExpanded = inputStateHolder.isTextExpanded, + inputType = messageCompositionInputStateHolder.inputType, + focusRequester = messageCompositionInputStateHolder.focusRequester, + onFocused = ::onInputFocused, + onToggleInputSize = messageCompositionInputStateHolder::toggleInputSize, + onTextCollapse = messageCompositionInputStateHolder::collapseText, + onCancelReply = messageCompositionHolder::clearReply, + onCancelEdit = ::cancelEdit, + onChangeSelfDeletionClicked = onChangeSelfDeletionClicked, + onSendButtonClicked = onSendButtonClicked, + onEditButtonClicked = { + onSendButtonClicked() + messageCompositionInputStateHolder.toComposing() + }, + onLineBottomYCoordinateChanged = { yCoordinate -> + cursorCoordinateY = yCoordinate + }, + onSelectedLineIndexChanged = { index -> + currentSelectedLineIndex = index + }, + showOptions = isImeVisible, + optionsSelected = inputStateHolder.optionsVisible, + onPlusClick = { + if (!hideRipple) { + showAttachments(!inputStateHolder.optionsVisible) + } + }, + modifier = fillRemainingSpaceOrWrapContent + .onKeyboardDismiss(inputStateHolder.optionsVisible) { + when (additionalOptionStateHolder.additionalOptionsSubMenuState) { + AdditionalOptionSubMenuState.Default -> { + inputStateHolder.showAttachments(false) + } - val mentionSearchResult = messageComposerViewState.value.mentionSearchResult - if (mentionSearchResult.isNotEmpty() && inputStateHolder.isTextExpanded - ) { - DropDownMentionsSuggestions( - currentSelectedLineIndex = currentSelectedLineIndex, - cursorCoordinateY = cursorCoordinateY, - membersToMention = mentionSearchResult, - searchQuery = messageComposerViewState.value.mentionSearchQuery, - onMentionPicked = { - messageCompositionHolder.addMention(it) - onClearMentionSearchResult() + AdditionalOptionSubMenuState.RecordAudio -> {} } - ) - } + }, + ) + + val mentionSearchResult = messageComposerViewState.value.mentionSearchResult + if (mentionSearchResult.isNotEmpty() && inputStateHolder.isTextExpanded + ) { + DropDownMentionsSuggestions( + currentSelectedLineIndex = currentSelectedLineIndex, + cursorCoordinateY = cursorCoordinateY, + membersToMention = mentionSearchResult, + searchQuery = messageComposerViewState.value.mentionSearchQuery, + onMentionPicked = { + messageCompositionHolder.addMention(it) + onClearMentionSearchResult() + } + ) } } - if (inputStateHolder.optionsVisible) { - if (additionalOptionStateHolder.additionalOptionsSubMenuState != AdditionalOptionSubMenuState.RecordAudio) { - AdditionalOptionsMenu( - conversationId = conversationId, - additionalOptionsState = additionalOptionStateHolder.additionalOptionState, - selectedOption = additionalOptionStateHolder.selectedOption, - isEditing = messageCompositionInputStateHolder.inputType is InputType.Editing, - isMentionActive = messageComposerViewState.value.mentionSearchResult.isNotEmpty(), - onMentionButtonClicked = messageCompositionHolder::startMention, - onOnSelfDeletingOptionClicked = { - additionalOptionStateHolder.toSelfDeletingOptionsMenu() - onChangeSelfDeletionClicked(it) - }, - onRichOptionButtonClicked = messageCompositionHolder::addOrRemoveMessageMarkdown, - onPingOptionClicked = onPingOptionClicked, - onAdditionalOptionsMenuClicked = { - if (!isKeyboardMoving) { - if (additionalOptionStateHolder.selectedOption == AdditionalOptionSelectItem.AttachFile) { - additionalOptionStateHolder.unselectAdditionalOptionsMenu() - messageCompositionInputStateHolder.toComposing() - } else { - showAdditionalOptionsMenu() - } - } - }, - onRichEditingButtonClicked = { - messageCompositionInputStateHolder.requestFocus() - additionalOptionStateHolder.toRichTextEditing() - }, - onCloseRichEditingButtonClicked = additionalOptionStateHolder::toAttachmentAndAdditionalOptionsMenu, - onDrawingModeClicked = { - showAdditionalOptionsMenu() - openDrawingCanvas() + if (isImeVisible) { + AdditionalOptionsMenu( + conversationId = conversationId, + additionalOptionsState = additionalOptionStateHolder.additionalOptionState, + selectedOption = additionalOptionStateHolder.selectedOption, + attachmentsVisible = inputStateHolder.optionsVisible, + isEditing = messageCompositionInputStateHolder.inputType is InputType.Editing, + isMentionActive = messageComposerViewState.value.mentionSearchResult.isNotEmpty(), + onMentionButtonClicked = messageCompositionHolder::startMention, + onOnSelfDeletingOptionClicked = onChangeSelfDeletionClicked, + onRichOptionButtonClicked = messageCompositionHolder::addOrRemoveMessageMarkdown, + onPingOptionClicked = onPingOptionClicked, + onAdditionalOptionsMenuClicked = { + if (!hideRipple) { + showAttachments(!inputStateHolder.optionsVisible) + } + }, + onRichEditingButtonClicked = { + messageCompositionInputStateHolder.requestFocus() + additionalOptionStateHolder.toRichTextEditing() + }, + onCloseRichEditingButtonClicked = additionalOptionStateHolder::toAttachmentAndAdditionalOptionsMenu, + onDrawingModeClicked = openDrawingCanvas, + ) + } + } + } + if ((inputStateHolder.optionsVisible || rippleProgress.value > 0f) && !bottomSheetVisible) { + Popup( + alignment = Alignment.BottomCenter, + properties = PopupProperties( + dismissOnBackPress = true, + dismissOnClickOutside = true + ), + offset = if (isImeVisible) { + IntOffset(0, 0) + } else { + with(density) { IntOffset(0, -dimensions().spacing64x.toPx().roundToInt()) } + }, + onDismissRequest = { + hideRipple = true + showAttachments(false) + } + ) { + val rippleColor = colorsScheme().messageComposerBackgroundColor + val shape = if (isImeVisible) { + RectangleShape + } else { + RoundedCornerShape(dimensions().corner14x) + } + + Box( + modifier = Modifier + .fillMaxWidth() + .height(inputStateHolder.calculateOptionsMenuHeight(additionalOptionStateHolder.additionalOptionsSubMenuState)) + .padding( + horizontal = if (isImeVisible) { + dimensions().spacing0x + } else { + dimensions().spacing8x } ) - } - Box( - modifier = Modifier - .height( - inputStateHolder.calculateOptionsMenuHeight(additionalOptionStateHolder.additionalOptionsSubMenuState) - ) - .fillMaxWidth() - .background(colorsScheme().messageComposerBackgroundColor) - ) { - androidx.compose.animation.AnimatedVisibility( - visible = inputStateHolder.subOptionsVisible, - enter = fadeIn(), - exit = fadeOut(), - ) { - AdditionalOptionSubMenu( - isFileSharingEnabled = messageComposerViewState.value.isFileSharingEnabled, - additionalOptionsState = additionalOptionStateHolder.additionalOptionsSubMenuState, - onRecordAudioMessageClicked = ::toAudioRecording, - onCloseAdditionalAttachment = ::toInitialAttachmentOptions, - onLocationPickerClicked = ::toLocationPicker, - onImagesPicked = onImagesPicked, - onAttachmentPicked = onAttachmentPicked, - onAudioRecorded = onAudioRecorded, - onLocationPicked = onLocationPicked, - onPermissionPermanentlyDenied = onPermissionPermanentlyDenied, - tempWritableImageUri = tempWritableImageUri, - tempWritableVideoUri = tempWritableVideoUri, - modifier = Modifier.fillMaxSize() - ) + .background( + color = Color.Transparent, + shape = shape + ) + .clip(shape) + .drawBehind { + if (!hideRipple || rippleProgress.value > 0f) { + val maxRadius = size.getDistanceToCorner(Offset(0f, 0f)) + val currentRadius = maxRadius * rippleProgress.value + + drawCircle( + color = rippleColor, + radius = currentRadius, + center = Offset( + 0f, + if (isImeVisible) { + 0f + } else { + size.height + } + ) + ) + } } - } + + ) { + AdditionalOptionSubMenu( + optionsVisible = inputStateHolder.optionsVisible, + isFileSharingEnabled = messageComposerViewState.value.isFileSharingEnabled, + additionalOptionsState = additionalOptionStateHolder.additionalOptionsSubMenuState, + onRecordAudioMessageClicked = ::toAudioRecording, + onCloseAdditionalAttachment = ::toInitialAttachmentOptions, + onLocationPickerClicked = onLocationClicked, + onImagesPicked = onImagesPicked, + onAttachmentPicked = onAttachmentPicked, + onAudioRecorded = onAudioRecorded, + onPermissionPermanentlyDenied = onPermissionPermanentlyDenied, + tempWritableImageUri = tempWritableImageUri, + tempWritableVideoUri = tempWritableVideoUri, + modifier = Modifier.fillMaxSize() + ) } } - } - BackHandler(inputStateHolder.inputType is InputType.Editing) { - cancelEdit() - } - BackHandler(isImeVisible || inputStateHolder.optionsVisible) { - inputStateHolder.collapseComposer(additionalOptionStateHolder.additionalOptionsSubMenuState) + BackHandler(inputStateHolder.optionsVisible) { + inputStateHolder.showAttachments(false) + } + BackHandler(inputStateHolder.inputType is InputType.Editing) { + cancelEdit() + } + BackHandler(isImeVisible || inputStateHolder.inputFocused) { + inputStateHolder.collapseComposer(additionalOptionStateHolder.additionalOptionsSubMenuState) + } } } } } -@OptIn(ExperimentalLayoutApi::class) -@Composable -private fun isKeyboardMoving(): Boolean { - val density = LocalDensity.current - val isImeVisible = WindowInsets.isImeVisible - val imeAnimationSource = WindowInsets.imeAnimationSource.getBottom(density) - val imeAnimationTarget = WindowInsets.imeAnimationTarget.getBottom(density) - return isImeVisible && imeAnimationSource != imeAnimationTarget +fun Size.getDistanceToCorner(corner: Offset): Float { + val cornerOffset = Offset(width - corner.x, height - corner.y) + return cornerOffset.getDistance() } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/KeyboardModifiers.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/KeyboardModifiers.kt new file mode 100644 index 00000000000..f92f4ffda20 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/KeyboardModifiers.kt @@ -0,0 +1,62 @@ +/* + * Wire + * Copyright (C) 2024 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.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreInterceptKeyBeforeSoftKeyboard +import androidx.compose.ui.input.key.type + +/** + * Enhances [Modifier] to handle the keyboard's dismiss event by providing a + * mechanism to intercept the event based on a specified condition. + * + * This function intercepts the keyboard hide button event. When `shouldIntercept` + * is true, it executes the provided callback to manage any necessary UI changes + * (e.g., hiding attachments) before the keyboard is dismissed. If `shouldIntercept` + * is false, the keyboard will dismiss normally. + * + * @param shouldIntercept A boolean flag that determines whether to intercept the + * keyboard hide event. True to handle custom actions before + * keyboard dismissal, false to allow normal dismissal. + * @param handleOnBackPressed A lambda function to execute if the intercept condition + * is met when the keyboard hide button is pressed. This + * function should contain actions such as hiding UI elements. + * @return [Modifier] The original [Modifier] enhanced with pre-intercept keyboard + * event handling capabilities. + */ +@OptIn(ExperimentalComposeUiApi::class) +fun Modifier.onKeyboardDismiss( + shouldIntercept: Boolean, + handleOnBackPressed: () -> Unit +): Modifier = + this.onPreInterceptKeyBeforeSoftKeyboard { event: KeyEvent -> + if (shouldIntercept && event.type == KeyEventType.KeyDown && + event.key.keyCode == KEYBOARD_HIDE_BUTTON_CODE + ) { + handleOnBackPressed.invoke() + return@onPreInterceptKeyBeforeSoftKeyboard true // Consumes the event, preventing + // further propagation. + } + false // Does not consume the event, allowing it to be handled by other components + } + +private const val KEYBOARD_HIDE_BUTTON_CODE = 17179869184 // The key code for the keyboard 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 c3125cf4683..4f51b6bb061 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 @@ -50,6 +50,7 @@ import com.wire.kalium.logic.util.isPositiveNotNull fun MessageComposeActions( conversationId: ConversationId, isEditing: Boolean, + attachmentsVisible: Boolean, selectedOption: AdditionalOptionSelectItem, onMentionButtonClicked: () -> Unit, onAdditionalOptionButtonClicked: () -> Unit, @@ -62,23 +63,24 @@ fun MessageComposeActions( ) { if (isEditing) { EditingActions( - selectedOption, - isMentionActive, - onRichEditingButtonClicked, - onMentionButtonClicked + selectedOption = selectedOption, + isMentionActive = isMentionActive, + onRichEditingButtonClicked = onRichEditingButtonClicked, + onMentionButtonClicked = onMentionButtonClicked ) } else { ComposingActions( - conversationId, - selectedOption, - isMentionActive, - onAdditionalOptionButtonClicked, - onRichEditingButtonClicked, - onGifButtonClicked, - onSelfDeletionOptionButtonClicked, - onPingButtonClicked, - onMentionButtonClicked, - onDrawingModeClicked + conversationId = conversationId, + selectedOption = selectedOption, + isMentionActive = isMentionActive, + attachmentsVisible = attachmentsVisible, + onAdditionalOptionButtonClicked = onAdditionalOptionButtonClicked, + onRichEditingButtonClicked = onRichEditingButtonClicked, + onGifButtonClicked = onGifButtonClicked, + onSelfDeletionOptionButtonClicked = onSelfDeletionOptionButtonClicked, + onPingButtonClicked = onPingButtonClicked, + onMentionButtonClicked = onMentionButtonClicked, + onDrawingModeClicked = onDrawingModeClicked ) } } @@ -87,6 +89,7 @@ fun MessageComposeActions( private fun ComposingActions( conversationId: ConversationId, selectedOption: AdditionalOptionSelectItem, + attachmentsVisible: Boolean, isMentionActive: Boolean, onAdditionalOptionButtonClicked: () -> Unit, onRichEditingButtonClicked: () -> Unit, @@ -107,7 +110,7 @@ private fun ComposingActions( ) { with(localFeatureVisibilityFlags) { AdditionalOptionButton( - isSelected = selectedOption == AdditionalOptionSelectItem.AttachFile, + isSelected = attachmentsVisible, onClick = onAdditionalOptionButtonClicked ) RichTextEditingAction( diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposer.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposer.kt index 56eddb164e0..418a1702dd6 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposer.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposer.kt @@ -40,8 +40,10 @@ 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.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.AnnotatedString @@ -73,11 +75,13 @@ import kotlin.math.roundToInt @Composable fun MessageComposer( conversationId: ConversationId, + bottomSheetVisible: Boolean, messageComposerStateHolder: MessageComposerStateHolder, messageListContent: @Composable () -> Unit, onSendMessageBundle: (MessageBundle) -> Unit, onPingOptionClicked: () -> Unit, onChangeSelfDeletionClicked: (currentlySelected: SelfDeletionTimer) -> Unit, + onLocationClicked: () -> Unit, onClearMentionSearchResult: () -> Unit, onPermissionPermanentlyDenied: (type: ConversationActionPermissionType) -> Unit, openDrawingCanvas: () -> Unit, @@ -126,6 +130,7 @@ fun MessageComposer( InteractionAvailability.ENABLED -> { EnabledMessageComposer( conversationId = conversationId, + bottomSheetVisible = bottomSheetVisible, messageComposerStateHolder = messageComposerStateHolder, messageListContent = messageListContent, onSendButtonClicked = { @@ -137,16 +142,8 @@ fun MessageComposer( onImagesPicked = onImagesPicked, onAttachmentPicked = { onSendMessageBundle(ComposableMessageBundle.UriPickedBundle(conversationId, it)) }, onAudioRecorded = { onSendMessageBundle(ComposableMessageBundle.AudioMessageBundle(conversationId, it)) }, - onLocationPicked = { - onSendMessageBundle( - ComposableMessageBundle.LocationBundle( - conversationId, - it.getFormattedAddress(), - it.location - ) - ) - }, onChangeSelfDeletionClicked = onChangeSelfDeletionClicked, + onLocationClicked = onLocationClicked, onClearMentionSearchResult = onClearMentionSearchResult, onPermissionPermanentlyDenied = onPermissionPermanentlyDenied, openDrawingCanvas = openDrawingCanvas, @@ -253,13 +250,17 @@ private fun BaseComposerPreview( } val messageTextState = rememberTextFieldState() val messageComposition = remember { mutableStateOf(MessageComposition(ConversationId("value", "domain"))) } - + val keyboardController = LocalSoftwareKeyboardController.current + val focusRequester = remember { FocusRequester() } MessageComposer( conversationId = ConversationId("value", "domain"), + bottomSheetVisible = false, messageComposerStateHolder = MessageComposerStateHolder( messageComposerViewState = messageComposerViewState, messageCompositionInputStateHolder = MessageCompositionInputStateHolder( messageTextState = messageTextState, + keyboardController = keyboardController, + focusRequester = focusRequester ), messageCompositionHolder = MessageCompositionHolder( messageComposition = messageComposition, @@ -274,6 +275,7 @@ private fun BaseComposerPreview( onPingOptionClicked = { }, messageListContent = { }, onChangeSelfDeletionClicked = { }, + onLocationClicked = {}, onClearMentionSearchResult = { }, onPermissionPermanentlyDenied = { }, onSendMessageBundle = { }, 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 7fe12f2e2d1..48e30853d6f 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 @@ -39,9 +39,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier @@ -52,8 +50,6 @@ import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.nativeKeyCode import androidx.compose.ui.input.key.onPreInterceptKeyBeforeSoftKeyboard -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.text.input.ImeAction @@ -92,7 +88,7 @@ fun ActiveMessageComposerInput( messageTextState: TextFieldState, isTextExpanded: Boolean, inputType: InputType, - inputFocused: Boolean, + focusRequester: FocusRequester, onSendButtonClicked: () -> Unit, onEditButtonClicked: () -> Unit, onChangeSelfDeletionClicked: (currentlySelected: SelfDeletionTimer) -> Unit, @@ -100,10 +96,11 @@ fun ActiveMessageComposerInput( onTextCollapse: () -> Unit, onCancelReply: () -> Unit, onCancelEdit: () -> Unit, - onInputFocusedChanged: (Boolean) -> Unit, + onFocused: () -> Unit, onSelectedLineIndexChanged: (Int) -> Unit, onLineBottomYCoordinateChanged: (Float) -> Unit, showOptions: Boolean, + optionsSelected: Boolean, onPlusClick: () -> Unit, modifier: Modifier = Modifier ) { @@ -134,13 +131,14 @@ fun ActiveMessageComposerInput( messageTextState = messageTextState, isTextExpanded = isTextExpanded, inputType = inputType, - inputFocused = inputFocused, + focusRequester = focusRequester, onSendButtonClicked = onSendButtonClicked, onChangeSelfDeletionClicked = onChangeSelfDeletionClicked, - onInputFocusedChanged = onInputFocusedChanged, + onFocused = onFocused, onSelectedLineIndexChanged = onSelectedLineIndexChanged, onLineBottomYCoordinateChanged = onLineBottomYCoordinateChanged, showOptions = showOptions, + optionsSelected = optionsSelected, onPlusClick = onPlusClick, onTextCollapse = onTextCollapse, modifier = Modifier @@ -170,13 +168,14 @@ private fun InputContent( messageTextState: TextFieldState, isTextExpanded: Boolean, inputType: InputType, - inputFocused: Boolean, + focusRequester: FocusRequester, onSendButtonClicked: () -> Unit, onChangeSelfDeletionClicked: (currentlySelected: SelfDeletionTimer) -> Unit, - onInputFocusedChanged: (Boolean) -> Unit, + onFocused: () -> Unit, onSelectedLineIndexChanged: (Int) -> Unit, onLineBottomYCoordinateChanged: (Float) -> Unit, showOptions: Boolean, + optionsSelected: Boolean, onPlusClick: () -> Unit, onTextCollapse: () -> Unit, modifier: Modifier = Modifier, @@ -197,7 +196,7 @@ private fun InputContent( ) { if (!showOptions && inputType is InputType.Composing) { AdditionalOptionButton( - isSelected = false, + isSelected = optionsSelected, onClick = onPlusClick, modifier = Modifier.padding(start = dimensions().spacing8x) ) @@ -207,12 +206,12 @@ private fun InputContent( val collapsedMaxHeight = dimensions().messageComposerActiveInputMaxHeight MessageComposerTextInput( isTextExpanded = isTextExpanded, - inputFocused = inputFocused, + focusRequester = focusRequester, colors = inputType.inputTextColor(isSelfDeleting = viewModel.state().duration != null), messageTextState = messageTextState, placeHolderText = viewModel.state().duration?.let { stringResource(id = R.string.self_deleting_message_label) } ?: inputType.labelText(), - onFocusChanged = onInputFocusedChanged, + onFocused = onFocused, onSelectedLineIndexChanged = onSelectedLineIndexChanged, onLineBottomYCoordinateChanged = onLineBottomYCoordinateChanged, onTextCollapse = onTextCollapse, @@ -267,38 +266,22 @@ private fun InputContent( @Composable private fun MessageComposerTextInput( isTextExpanded: Boolean, - inputFocused: Boolean, + focusRequester: FocusRequester, colors: WireTextFieldColors, messageTextState: TextFieldState, placeHolderText: String, onTextCollapse: () -> Unit, + onFocused: () -> Unit, modifier: Modifier = Modifier, - onFocusChanged: (Boolean) -> Unit = {}, onSelectedLineIndexChanged: (Int) -> Unit = { }, onLineBottomYCoordinateChanged: (Float) -> Unit = { } ) { - val keyboardController = LocalSoftwareKeyboardController.current - val focusRequester = remember { FocusRequester() } val interactionSource = remember { MutableInteractionSource() } val isPressed by interactionSource.collectIsPressedAsState() - var isReadOnly by remember { mutableStateOf(false) } - val focusManager = LocalFocusManager.current - - LaunchedEffect(inputFocused) { - if (inputFocused) { - isReadOnly = false - keyboardController?.show() - focusRequester.requestFocus() - } else { - isReadOnly = true - focusManager.clearFocus() - keyboardController?.hide() - } - } LaunchedEffect(isPressed) { if (isPressed) { - onFocusChanged(true) + onFocused() } } @@ -309,13 +292,13 @@ private fun MessageComposerTextInput( textStyle = MaterialTheme.wireTypography.body01, // Add an extra space so that the cursor is placed one space before "Type a message" placeholderText = " $placeHolderText", - state = if (isReadOnly) WireTextFieldState.ReadOnly else WireTextFieldState.Default, + state = WireTextFieldState.Default, keyboardOptions = KeyboardOptions.DefaultText.copy(imeAction = ImeAction.None), modifier = modifier .focusRequester(focusRequester) .onFocusChanged { focusState -> if (focusState.isFocused) { - onFocusChanged(true) + onFocused() } } .onPreInterceptKeyBeforeSoftKeyboard { event -> @@ -374,7 +357,7 @@ private fun PreviewActiveMessageComposerInput(inputType: InputType, isTextExpand messageTextState = rememberTextFieldState("abc"), isTextExpanded = isTextExpanded, inputType = inputType, - inputFocused = false, + focusRequester = FocusRequester(), onSendButtonClicked = {}, onEditButtonClicked = {}, onChangeSelfDeletionClicked = {}, @@ -382,10 +365,11 @@ private fun PreviewActiveMessageComposerInput(inputType: InputType, isTextExpand onTextCollapse = {}, onCancelReply = {}, onCancelEdit = {}, - onInputFocusedChanged = {}, + onFocused = {}, onSelectedLineIndexChanged = {}, onLineBottomYCoordinateChanged = {}, showOptions = true, + optionsSelected = true, onPlusClick = {} ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/RichTextOptions.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/RichTextOptions.kt index 071a3a9287f..25efc48beef 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/RichTextOptions.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/RichTextOptions.kt @@ -18,13 +18,16 @@ package com.wire.android.ui.home.messagecomposer import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -35,6 +38,7 @@ import androidx.compose.ui.res.stringResource import com.wire.android.R import com.wire.android.ui.common.button.WireSecondaryIconButton import com.wire.android.ui.common.dimensions +import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.theme.wireDimensions @Composable @@ -42,33 +46,38 @@ fun RichTextOptions( onRichTextHeaderButtonClicked: () -> Unit, onRichTextBoldButtonClicked: () -> Unit, onRichTextItalicButtonClicked: () -> Unit, - onCloseRichTextEditingButtonClicked: () -> Unit + onCloseRichTextEditingButtonClicked: () -> Unit, + modifier: Modifier = Modifier ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Absolute.SpaceEvenly, - modifier = Modifier.wrapContentSize() - ) { - val modifier = Modifier - .fillMaxWidth() - .weight(1f) - .padding(horizontal = dimensions().spacing0x) + Column(modifier.wrapContentSize()) { + HorizontalDivider(color = MaterialTheme.wireColorScheme.outline) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Absolute.SpaceEvenly, + modifier = Modifier.wrapContentSize() + .height(dimensions().spacing56x) + ) { + val iconModifier = Modifier + .fillMaxWidth() + .weight(1f) + .padding(horizontal = dimensions().spacing0x) - HeaderButton( - modifier = modifier, - onRichTextHeaderButtonClicked = onRichTextHeaderButtonClicked - ) - BoldButton( - modifier = modifier, - onRichTextBoldButtonClicked = onRichTextBoldButtonClicked - ) - ItalicButton( - modifier = modifier, - onRichTextItalicButtonClicked = onRichTextItalicButtonClicked, - ) - CloseButton( - onCloseRichTextEditingButtonClicked = onCloseRichTextEditingButtonClicked - ) + HeaderButton( + modifier = iconModifier, + onRichTextHeaderButtonClicked = onRichTextHeaderButtonClicked + ) + BoldButton( + modifier = iconModifier, + onRichTextBoldButtonClicked = onRichTextBoldButtonClicked + ) + ItalicButton( + modifier = iconModifier, + onRichTextItalicButtonClicked = onRichTextItalicButtonClicked, + ) + CloseButton( + onCloseRichTextEditingButtonClicked = onCloseRichTextEditingButtonClicked + ) + } } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/location/LocationPickerComponent.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/location/LocationPickerComponent.kt index 5d289bbb896..f1004cccc81 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/location/LocationPickerComponent.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/location/LocationPickerComponent.kt @@ -49,8 +49,8 @@ import com.wire.android.ui.common.bottomsheet.MenuItemIcon import com.wire.android.ui.common.bottomsheet.MenuModalSheetHeader import com.wire.android.ui.common.bottomsheet.WireMenuModalSheetContent import com.wire.android.ui.common.bottomsheet.WireModalSheetLayout +import com.wire.android.ui.common.bottomsheet.WireModalSheetState import com.wire.android.ui.common.bottomsheet.rememberWireModalSheetState -import com.wire.android.ui.common.bottomsheet.show import com.wire.android.ui.common.button.WireButtonState import com.wire.android.ui.common.button.WirePrimaryButton import com.wire.android.ui.common.dimensions @@ -70,11 +70,10 @@ import com.wire.android.util.permission.rememberCurrentLocationPermissionFlow @Composable fun LocationPickerComponent( onLocationPicked: (GeoLocatedAddress) -> Unit, - onLocationClosed: () -> Unit, modifier: Modifier = Modifier, - viewModel: LocationPickerViewModel = hiltViewModel() + viewModel: LocationPickerViewModel = hiltViewModel(), + sheetState: WireModalSheetState = rememberWireModalSheetState(), ) { - val sheetState = rememberWireModalSheetState(onDismissAction = onLocationClosed) val locationFlow = rememberCurrentLocationPermissionFlow( onAllPermissionsGranted = viewModel::getCurrentLocation, @@ -82,16 +81,14 @@ fun LocationPickerComponent( onAnyPermissionPermanentlyDenied = viewModel::onPermissionPermanentlyDenied ) - LaunchedEffect(Unit) { - sheetState.show() - locationFlow.launch() - } - with(viewModel.state) { WireModalSheetLayout( modifier = modifier, sheetState = sheetState, ) { + LaunchedEffect(Unit) { + locationFlow.launch() + } WireMenuModalSheetContent( header = MenuModalSheetHeader.Visible(title = stringResource(R.string.location_attachment_share_title)), menuItems = buildList { @@ -121,7 +118,7 @@ fun LocationPickerComponent( LocationErrorMessage { sheetState.hide { viewModel.onLocationSharingErrorDialogDiscarded() - onLocationClosed() + sheetState.hide() } } } @@ -129,7 +126,7 @@ fun LocationPickerComponent( isLocationLoading = isLocationLoading, geoLocatedAddress = geoLocatedAddress, onLocationPicked = onLocationPicked, - onLocationClosed = onLocationClosed + onLocationClosed = sheetState::hide ) } } @@ -141,7 +138,7 @@ fun LocationPickerComponent( body = R.string.location_app_permission_dialog_body, onDismiss = { viewModel.onPermissionsDialogDiscarded() - onLocationClosed() + sheetState.hide() } ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/location/LocationPickerState.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/location/LocationPickerState.kt index 5713e48d639..09e79ad0149 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/location/LocationPickerState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/location/LocationPickerState.kt @@ -22,5 +22,5 @@ data class LocationPickerState( val isLocationLoading: Boolean = false, val isPermissionDiscarded: Boolean = false, val showPermissionDeniedDialog: Boolean = false, - val showLocationSharingError: Boolean = false, + val showLocationSharingError: Boolean = false ) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/AdditionalOptionMenuState.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/AdditionalOptionMenuState.kt index 93a8365e703..1c3827bbb15 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/AdditionalOptionMenuState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/AdditionalOptionMenuState.kt @@ -25,25 +25,16 @@ import androidx.compose.runtime.setValue enum class AdditionalOptionMenuState { AttachmentAndAdditionalOptionsMenu, - RichTextEditing, - Hidden + RichTextEditing } enum class AdditionalOptionSubMenuState { + Default, RecordAudio, - AttachFile, - AttachImage, - Emoji, - Location, - Gif; } enum class AdditionalOptionSelectItem { RichTextEditing, - - // it's only used to show keyboard after self deleting bottom sheet collapses - SelfDeleting, - AttachFile, None, } @@ -52,33 +43,24 @@ class AdditionalOptionStateHolder { var selectedOption by mutableStateOf(AdditionalOptionSelectItem.None) var additionalOptionsSubMenuState: AdditionalOptionSubMenuState by mutableStateOf( - AdditionalOptionSubMenuState.AttachFile + AdditionalOptionSubMenuState.Default ) private set var additionalOptionState: AdditionalOptionMenuState by mutableStateOf(AdditionalOptionMenuState.AttachmentAndAdditionalOptionsMenu) private set - fun showAdditionalOptionsMenu() { - selectedOption = AdditionalOptionSelectItem.AttachFile - additionalOptionsSubMenuState = AdditionalOptionSubMenuState.AttachFile - } - fun unselectAdditionalOptionsMenu() { selectedOption = AdditionalOptionSelectItem.None + additionalOptionsSubMenuState = AdditionalOptionSubMenuState.Default } fun toAudioRecording() { additionalOptionsSubMenuState = AdditionalOptionSubMenuState.RecordAudio } - fun toLocationPicker() { - additionalOptionsSubMenuState = AdditionalOptionSubMenuState.Location - additionalOptionState = AdditionalOptionMenuState.AttachmentAndAdditionalOptionsMenu - } - fun toInitialAttachmentOptionsMenu() { - additionalOptionsSubMenuState = AdditionalOptionSubMenuState.AttachFile + additionalOptionsSubMenuState = AdditionalOptionSubMenuState.Default additionalOptionState = AdditionalOptionMenuState.AttachmentAndAdditionalOptionsMenu } @@ -91,10 +73,6 @@ class AdditionalOptionStateHolder { unselectAdditionalOptionsMenu() } - fun toSelfDeletingOptionsMenu() { - selectedOption = AdditionalOptionSelectItem.SelfDeleting - } - companion object { fun saver(): Saver = Saver( save = { diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageComposerStateHolder.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageComposerStateHolder.kt index 6607db0995c..a17805f2ccb 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageComposerStateHolder.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageComposerStateHolder.kt @@ -26,7 +26,9 @@ import androidx.compose.runtime.State import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import com.wire.android.ui.home.conversations.MessageComposerViewState import com.wire.android.ui.home.conversations.model.UIMessage import com.wire.android.ui.home.messagecomposer.model.MessageComposition @@ -69,15 +71,23 @@ fun rememberMessageComposerStateHolder( LaunchedEffect(Unit) { messageCompositionHolder.handleMessageTextUpdates() } + val keyboardController = LocalSoftwareKeyboardController.current + val focusRequester = remember { + FocusRequester() + } val messageCompositionInputStateHolder = rememberSaveable( saver = MessageCompositionInputStateHolder.saver( messageTextState = messageTextState, + keyboardController = keyboardController, + focusRequester = focusRequester, density = density ) ) { MessageCompositionInputStateHolder( messageTextState = messageTextState, + keyboardController = keyboardController, + focusRequester = focusRequester ) } @@ -119,25 +129,15 @@ class MessageComposerStateHolder( messageCompositionInputStateHolder.toComposing() } - fun onInputFocusedChanged(onFocused: Boolean) { - if (onFocused) { - additionalOptionStateHolder.unselectAdditionalOptionsMenu() - messageCompositionInputStateHolder.requestFocus() - } else { - messageCompositionInputStateHolder.clearFocus() - } + fun onInputFocused() { + additionalOptionStateHolder.unselectAdditionalOptionsMenu() + messageCompositionInputStateHolder.setFocused() } fun toAudioRecording() { - messageCompositionInputStateHolder.showOptions() additionalOptionStateHolder.toAudioRecording() } - fun toLocationPicker() { - messageCompositionInputStateHolder.showOptions() - additionalOptionStateHolder.toLocationPicker() - } - fun toInitialAttachmentOptions() { additionalOptionStateHolder.toInitialAttachmentOptionsMenu() } @@ -147,9 +147,8 @@ class MessageComposerStateHolder( messageCompositionHolder.clearMessage() } - fun showAdditionalOptionsMenu() { - messageCompositionInputStateHolder.showOptions() - additionalOptionStateHolder.showAdditionalOptionsMenu() + fun showAttachments(showOptions: Boolean) { + messageCompositionInputStateHolder.showAttachments(showOptions) } fun clearMessage() { diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionInputStateHolder.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionInputStateHolder.kt index f7042cf42b8..8725f4bbf74 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionInputStateHolder.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionInputStateHolder.kt @@ -26,7 +26,9 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.setValue +import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.SoftwareKeyboardController import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp @@ -37,32 +39,24 @@ import com.wire.android.ui.common.colorsScheme import com.wire.android.ui.common.textfield.WireTextFieldColors import com.wire.android.ui.common.textfield.wireTextFieldColors import com.wire.android.util.isNotMarkdownBlank -import com.wire.android.util.ui.KeyboardHeight @Stable -class MessageCompositionInputStateHolder(val messageTextState: TextFieldState) { +class MessageCompositionInputStateHolder( + val messageTextState: TextFieldState, + private val keyboardController: SoftwareKeyboardController?, + val focusRequester: FocusRequester +) { var inputFocused: Boolean by mutableStateOf(false) - private set - var keyboardHeight by mutableStateOf(KeyboardHeight.default) - private set + var keyboardHeight by mutableStateOf(0.dp) var optionsHeight by mutableStateOf(0.dp) private set - var optionsVisible by mutableStateOf(false) - private set - - var subOptionsVisible by mutableStateOf(false) - private set - var isTextExpanded by mutableStateOf(false) private set - var initialKeyboardHeight by mutableStateOf(0.dp) - private set - - var previousOffset by mutableStateOf(0.dp) + var optionsVisible by mutableStateOf(false) private set private var compositionState: CompositionState by mutableStateOf(CompositionState.Composing) @@ -81,44 +75,34 @@ class MessageCompositionInputStateHolder(val messageTextState: TextFieldState) { fun handleImeOffsetChange(offset: Dp, navBarHeight: Dp, source: Dp, target: Dp) { val actualOffset = max(offset - navBarHeight, 0.dp) + val actualTarget = max(target - navBarHeight, 0.dp) // this check secures that if some additional space will be added to keyboard // like gifs search it will save initial keyboard height - if (source == target && source > 0.dp && initialKeyboardHeight == 0.dp) { - initialKeyboardHeight = source - navBarHeight + if (source == target && source > 0.dp) { + optionsHeight = actualOffset } - if (previousOffset < actualOffset) { - - // only if the real goal of this ime offset increase is to really open the keyboard - // otherwise it can mean the keyboard is still in a process of hiding from the previous screen and ultimately won't be shown - // in this case we don't want to show and hide the options for a short time as it will only make unwanted blink effect - if (target > 0.dp) { - optionsVisible = true - if (!subOptionsVisible || optionsHeight <= actualOffset) { - optionsHeight = actualOffset - subOptionsVisible = false + if (source == target) { + if (source > 0.dp) { + if (keyboardHeight == 0.dp) { + keyboardHeight = actualOffset } - } - } else if (previousOffset > actualOffset) { - if (!subOptionsVisible) { optionsHeight = actualOffset - if (actualOffset == 0.dp) { - optionsVisible = false - isTextExpanded = false - } } } - previousOffset = actualOffset - - if (keyboardHeight == actualOffset) { - subOptionsVisible = false + if (actualTarget == 0.dp) { + if (keyboardHeight > 0.dp) { + optionsHeight = keyboardHeight + } + inputFocused = false + isTextExpanded = false } + } - if (keyboardHeight < actualOffset) { - keyboardHeight = actualOffset - } + fun showAttachments(showOptions: Boolean) { + optionsVisible = showOptions } fun toEdit(editMessageText: String) { @@ -139,59 +123,47 @@ class MessageCompositionInputStateHolder(val messageTextState: TextFieldState) { isTextExpanded = false } - fun clearFocus() { - inputFocused = false - } - - fun requestFocus() { + fun setFocused() { inputFocused = true + keyboardController?.show() } - fun showOptions() { - optionsVisible = true - subOptionsVisible = true - if (initialKeyboardHeight > 0.dp) { - optionsHeight = initialKeyboardHeight - } else { - optionsHeight = keyboardHeight + fun requestFocus() { + if (!inputFocused) { + focusRequester.requestFocus() + focusRequester.captureFocus() // TODO check } - clearFocus() + keyboardController?.show() + inputFocused = true } fun collapseComposer(additionalOptionsSubMenuState: AdditionalOptionSubMenuState? = null) { if (additionalOptionsSubMenuState != AdditionalOptionSubMenuState.RecordAudio) { - optionsVisible = false - subOptionsVisible = false isTextExpanded = false - optionsHeight = 0.dp - inputFocused = false } } fun calculateOptionsMenuHeight(additionalOptionsSubMenuState: AdditionalOptionSubMenuState): Dp { - return optionsHeight + if (additionalOptionsSubMenuState != AdditionalOptionSubMenuState.RecordAudio) 0.dp else composeTextHeight + return max(optionsHeight, 250.dp) + if (additionalOptionsSubMenuState != AdditionalOptionSubMenuState.RecordAudio) { + 0.dp + } else { + composeTextHeight + } } @Suppress("LongParameterList") @VisibleForTesting fun updateValuesForTesting( - keyboardHeight: Dp = KeyboardHeight.default, - previousOffset: Dp = 0.dp, - showSubOptions: Boolean = false, + keyboardHeight: Dp = 250.dp, optionsHeight: Dp = 0.dp, - showOptions: Boolean = false, - initialKeyboardHeight: Dp = 0.dp + inputFocused: Boolean = false ) { this.keyboardHeight = keyboardHeight - this.previousOffset = previousOffset - this.subOptionsVisible = showSubOptions this.optionsHeight = optionsHeight - this.optionsVisible = showOptions - this.initialKeyboardHeight = initialKeyboardHeight + this.inputFocused = inputFocused } companion object { - /** * This height was based on the size of Input Text + Additional Options (Text Format, Ping, etc) */ @@ -199,6 +171,8 @@ class MessageCompositionInputStateHolder(val messageTextState: TextFieldState) { fun saver( messageTextState: TextFieldState, + keyboardController: SoftwareKeyboardController?, + focusRequester: FocusRequester, density: Density ): Saver = Saver( save = { @@ -207,10 +181,7 @@ class MessageCompositionInputStateHolder(val messageTextState: TextFieldState) { it.inputFocused, it.keyboardHeight.toPx(), it.optionsHeight.toPx(), - it.optionsVisible, - it.subOptionsVisible, - it.isTextExpanded, - it.previousOffset.toPx() + it.isTextExpanded ) } }, @@ -218,14 +189,13 @@ class MessageCompositionInputStateHolder(val messageTextState: TextFieldState) { with(density) { MessageCompositionInputStateHolder( messageTextState = messageTextState, + keyboardController = keyboardController, + focusRequester = focusRequester ).apply { inputFocused = savedState[0] as Boolean keyboardHeight = (savedState[1] as Float).toDp() optionsHeight = (savedState[2] as Float).toDp() - optionsVisible = savedState[3] as Boolean - subOptionsVisible = savedState[4] as Boolean - isTextExpanded = savedState[5] as Boolean - previousOffset = (savedState[6] as Float).toDp() + isTextExpanded = savedState[3] as Boolean } } } diff --git a/app/src/main/kotlin/com/wire/android/util/ui/KeyboardHeight.kt b/app/src/main/kotlin/com/wire/android/util/ui/KeyboardHeight.kt deleted file mode 100644 index 6601bb3bdc6..00000000000 --- a/app/src/main/kotlin/com/wire/android/util/ui/KeyboardHeight.kt +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Wire - * Copyright (C) 2024 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.util.ui - -import androidx.compose.ui.unit.dp - -object KeyboardHeight { - val default = 250.dp -} diff --git a/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposerStateHolderTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposerStateHolderTest.kt index 5e1a9f66ae5..5d22f50fb8b 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposerStateHolderTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposerStateHolderTest.kt @@ -23,13 +23,13 @@ import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.focus.FocusRequester import com.wire.android.config.CoroutineTestExtension import com.wire.android.config.SnapshotExtension import com.wire.android.framework.TestConversation import com.wire.android.ui.home.conversations.MessageComposerViewState import com.wire.android.ui.home.conversations.mock.mockMessageWithText import com.wire.android.ui.home.messagecomposer.model.MessageComposition -import com.wire.android.ui.home.messagecomposer.state.AdditionalOptionSelectItem import com.wire.android.ui.home.messagecomposer.state.AdditionalOptionStateHolder import com.wire.android.ui.home.messagecomposer.state.AdditionalOptionSubMenuState import com.wire.android.ui.home.messagecomposer.state.InputType @@ -38,6 +38,7 @@ import com.wire.android.ui.home.messagecomposer.state.MessageCompositionHolder import com.wire.android.ui.home.messagecomposer.state.MessageCompositionInputStateHolder import com.wire.android.util.EMPTY import io.mockk.MockKAnnotations +import io.mockk.every import io.mockk.impl.annotations.MockK import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest @@ -54,6 +55,9 @@ class MessageComposerStateHolderTest { @MockK lateinit var context: Context + @MockK + lateinit var focusRequester: FocusRequester + private lateinit var messageComposerViewState: MutableState private lateinit var messageComposition: MutableState private lateinit var messageCompositionInputStateHolder: MessageCompositionInputStateHolder @@ -65,10 +69,16 @@ class MessageComposerStateHolderTest { @BeforeEach fun before() { MockKAnnotations.init(this, relaxUnitFun = true) + every { focusRequester.requestFocus() } returns Unit + every { focusRequester.captureFocus() } returns true messageComposerViewState = mutableStateOf(MessageComposerViewState()) messageComposition = mutableStateOf(MessageComposition(TestConversation.ID)) messageTextState = TextFieldState() - messageCompositionInputStateHolder = MessageCompositionInputStateHolder(messageTextState = messageTextState) + messageCompositionInputStateHolder = MessageCompositionInputStateHolder( + messageTextState = messageTextState, + keyboardController = null, + focusRequester = focusRequester + ) messageCompositionHolder = MessageCompositionHolder( messageComposition = messageComposition, messageTextState = messageTextState, @@ -153,30 +163,16 @@ class MessageComposerStateHolderTest { assertEquals(currentText, messageCompositionHolder.messageTextState.text.toString()) } - @Test - fun `given state, when input focus change to false, then clear focus`() = runTest { - // given - // when - state.onInputFocusedChanged(onFocused = false) - - // then - assertEquals(false, messageCompositionInputStateHolder.inputFocused) - } - @Test fun `given state, when requesting to show additional options menu, then additional options menu is shown`() = runTest { // given // when - state.showAdditionalOptionsMenu() + state.showAttachments(true) // then assertEquals( - AdditionalOptionSelectItem.AttachFile, - additionalOptionStateHolder.selectedOption - ) - assertEquals( - AdditionalOptionSubMenuState.AttachFile, + AdditionalOptionSubMenuState.Default, additionalOptionStateHolder.additionalOptionsSubMenuState ) assertEquals(false, messageCompositionInputStateHolder.inputFocused) diff --git a/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionInputStateHolderTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionInputStateHolderTest.kt index 40a8b329c7f..9364fc0fd32 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionInputStateHolderTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionInputStateHolderTest.kt @@ -15,15 +15,24 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see http://www.gnu.org/licenses/. */ + +@file:Suppress("MaxLineLength") + package com.wire.android.ui.home.messagecomposer.state import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.platform.SoftwareKeyboardController import androidx.compose.ui.unit.dp import com.wire.android.config.CoroutineTestExtension import com.wire.android.config.SnapshotExtension -import com.wire.android.util.EMPTY +import io.mockk.MockKAnnotations +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.amshove.kluent.shouldBeEqualTo import org.amshove.kluent.shouldBeInstanceOf @@ -35,378 +44,211 @@ import org.junit.jupiter.api.extension.ExtendWith class MessageCompositionInputStateHolderTest { @Test - fun `when offset increases and is bigger than previous and options height, options height is updated`() = runTest { - val (state, _) = Arrangement().arrange() - // When - state.handleImeOffsetChange( - 50.dp, - NAVIGATION_BAR_HEIGHT, - SOURCE, - TARGET - ) - - // Then - state.optionsHeight shouldBeEqualTo 50.dp - state.subOptionsVisible shouldBeEqualTo false - } - - @Test - fun `when offset decreases and showSubOptions is false, options height is updated`() = runTest { - // Given - val (state, _) = Arrangement().arrange() - state.updateValuesForTesting(previousOffset = 50.dp) - - // When - state.handleImeOffsetChange( - 20.dp, - NAVIGATION_BAR_HEIGHT, - SOURCE, - TARGET - ) - - // Then - state.optionsHeight shouldBeEqualTo 20.dp - } - - @Test - fun `when offset decreases to zero, showOptions and isTextExpanded are set to false`() = runTest { - // Given - val (state, _) = Arrangement().arrange() - state.updateValuesForTesting(previousOffset = 50.dp) - - // When - state.handleImeOffsetChange( - 0.dp, - NAVIGATION_BAR_HEIGHT, - SOURCE, - TARGET - ) - - // Then - state.optionsVisible shouldBeEqualTo false - state.isTextExpanded shouldBeEqualTo false - } - - @Test - fun `when offset equals keyboard height, showSubOptions is set to false`() = runTest { - // Given - val (state, _) = Arrangement().arrange() - state.updateValuesForTesting(keyboardHeight = 30.dp) - - // When - state.handleImeOffsetChange( - 30.dp, - NAVIGATION_BAR_HEIGHT, - SOURCE, - TARGET - ) - - // Then - state.subOptionsVisible shouldBeEqualTo false - } - - @Test - fun `when offset is greater than keyboard height, keyboardHeight is updated`() = runTest { - // Given - val (state, _) = Arrangement().arrange() - state.updateValuesForTesting(keyboardHeight = 20.dp) - - // When - state.handleImeOffsetChange( - 30.dp, - NAVIGATION_BAR_HEIGHT, - SOURCE, - TARGET - ) - - // Then - state.keyboardHeight shouldBeEqualTo 30.dp - } - - @Test - fun `when offset increases and is greater than keyboardHeight but is less than previousOffset, keyboardHeight is updated`() = runTest { - // Given - val (state, _) = Arrangement().arrange() - state.updateValuesForTesting(previousOffset = 50.dp, keyboardHeight = 20.dp) + fun `given source and target differ and target is zero when IME offset changes then options height resets and input focus is lost`() = + runTest { + // Given + val (state, _) = Arrangement().arrange() + val initialHeight = 30.dp + state.updateValuesForTesting(keyboardHeight = initialHeight) + val sourceHeight = 50.dp + val targetHeight = 0.dp - // When - state.handleImeOffsetChange( - 30.dp, - NAVIGATION_BAR_HEIGHT, - SOURCE, - TARGET - ) + // When + state.handleImeOffsetChange(40.dp, 0.dp, sourceHeight, targetHeight) + advanceUntilIdle() - // Then - state.keyboardHeight shouldBeEqualTo 30.dp - state.optionsHeight shouldBeEqualTo 30.dp - } + // Then + state.optionsHeight shouldBeEqualTo initialHeight + state.inputFocused shouldBeEqualTo false + state.isTextExpanded shouldBeEqualTo false + } @Test - fun `when offset decreases, showSubOptions is true, and actualOffset is greater than optionsHeight, values remain unchanged`() = + fun `given initial keyboard height is non-zero when IME offset increases then keyboard height does not update but options height updates`() = runTest { // Given val (state, _) = Arrangement().arrange() - state.updateValuesForTesting( - previousOffset = 50.dp, - keyboardHeight = 20.dp, - showSubOptions = true, - optionsHeight = 10.dp - ) + val initialHeight = 30.dp + val newHeight = 50.dp + state.updateValuesForTesting(keyboardHeight = initialHeight) // When - state.handleImeOffsetChange( - 30.dp, - NAVIGATION_BAR_HEIGHT, - SOURCE, - TARGET - ) + state.handleImeOffsetChange(newHeight, 0.dp, newHeight, newHeight) // Then - state.optionsHeight shouldBeEqualTo 10.dp + state.keyboardHeight shouldBeEqualTo initialHeight + state.optionsHeight shouldBeEqualTo newHeight } @Test - fun `when offset decreases, showSubOptions is false, and actualOffset is greater than optionsHeight, optionsHeight is updated`() = + fun `given initial keyboard height when keyboard height changes then both keyboardHeight and optionsHeight update correctly`() = runTest { // Given val (state, _) = Arrangement().arrange() - state.updateValuesForTesting( - previousOffset = 50.dp, - keyboardHeight = 20.dp, - showSubOptions = false, - optionsHeight = 10.dp - ) + val newOffset = 100.dp // When - state.handleImeOffsetChange( - 30.dp, - NAVIGATION_BAR_HEIGHT, - SOURCE, - TARGET - ) + state.handleImeOffsetChange(newOffset, 0.dp, newOffset, newOffset) // Then - state.optionsHeight shouldBeEqualTo 30.dp + state.keyboardHeight shouldBeEqualTo newOffset + state.optionsHeight shouldBeEqualTo newOffset } @Test - fun `when offset is the same as previousOffset and greater than current keyboardHeight, keyboardHeight is updated`() = runTest { + fun `given text is not blank when transitioning to composing state then send button is enabled`() = runTest { // Given - val (state, _) = Arrangement().arrange() - state.updateValuesForTesting(previousOffset = 40.dp, keyboardHeight = 20.dp) + val (state, _) = Arrangement().withText("Hello World").arrange() // When - state.handleImeOffsetChange( - 40.dp, - NAVIGATION_BAR_HEIGHT, - SOURCE, - TARGET - ) + state.toComposing() // Then - state.keyboardHeight shouldBeEqualTo 40.dp - state.optionsHeight shouldBeEqualTo 0.dp + state.inputType.shouldBeInstanceOf().isSendButtonEnabled shouldBeEqualTo true } @Test - fun `given first keyboard appear when source equals target, then initialKeyboardHeight is set`() = runTest { + fun `given text has changed and is not blank when transitioning to editing state then edit button is enabled`() = runTest { // Given - val (state, _) = Arrangement().arrange() - val imeValue = 50.dp - state.updateValuesForTesting(initialKeyboardHeight = 0.dp) + val initialText = "Hello" + val newText = "Hello World" + val (state, _) = Arrangement().withText(initialText).arrange() + state.toEdit(newText) // When - state.handleImeOffsetChange(20.dp, NAVIGATION_BAR_HEIGHT, source = imeValue, target = imeValue) + val result = state.inputType as InputType.Editing // Then - state.initialKeyboardHeight shouldBeEqualTo imeValue + result.isEditButtonEnabled shouldBeEqualTo true } @Test - fun `given extended keyboard height when attachment button is clicked, then keyboardHeight is set to initialKeyboardHeight`() = - runTest { - // Given - val (state, _) = Arrangement().arrange() - val initialKeyboardHeight = 10.dp - state.updateValuesForTesting(previousOffset = 40.dp, keyboardHeight = 20.dp, initialKeyboardHeight = initialKeyboardHeight) - - // When - state.showOptions() - state.handleImeOffsetChange(0.dp, NAVIGATION_BAR_HEIGHT, source = TARGET, target = SOURCE) - - // Then - state.keyboardHeight shouldBeEqualTo 20.dp - state.optionsHeight shouldBeEqualTo initialKeyboardHeight - } - - @Test - fun `when offset decreases but is not zero, only optionsHeight is updated`() = runTest { + fun `given text size toggle is activated when toggling text size then isTextExpanded state changes correctly`() = runTest { // Given val (state, _) = Arrangement().arrange() - state.updateValuesForTesting(previousOffset = 50.dp) - - // When - state.handleImeOffsetChange( - 10.dp, - NAVIGATION_BAR_HEIGHT, - SOURCE, - TARGET - ) - // Then - state.optionsHeight shouldBeEqualTo 10.dp - state.optionsVisible shouldBeEqualTo false + // When & Then + state.toggleInputSize() + state.isTextExpanded shouldBeEqualTo true + state.toggleInputSize() state.isTextExpanded shouldBeEqualTo false } @Test - fun `when keyboard is still in a process of hiding from the previous screen after navigating, options should not be visible`() = - runTest { - // Given - val (state, _) = Arrangement().arrange() - state.updateValuesForTesting(previousOffset = 0.dp) - - // When - state.handleImeOffsetChange( - offset = 40.dp, - navBarHeight = NAVIGATION_BAR_HEIGHT, - source = 50.dp, - target = 0.dp - ) - - // Then - state.optionsHeight shouldBeEqualTo 0.dp - state.optionsVisible shouldBeEqualTo false - state.isTextExpanded shouldBeEqualTo false - } - - @Test - fun `given empty text, when composing, then send button should be disabled`() = runTest { + fun `given text is expanded when collapsing text then isTextExpanded resets to false`() = runTest { // Given - val messageText = String.EMPTY - val (state, _) = Arrangement() - .withText(messageText) - .arrange() + val (state, _) = Arrangement().arrange() + state.toggleInputSize() // When - state.toComposing() + state.collapseText() // Then - state.inputType.shouldBeInstanceOf().let { - it.isSendButtonEnabled shouldBeEqualTo false - } + state.isTextExpanded shouldBeEqualTo false } @Test - fun `given non-empty text but with only empty markdown, when composing, then send button should be disabled`() = runTest { + fun `given keyboard is focused when setting focus then inputFocused is true and keyboard shows`() = runTest { // Given - val messageText = "# " // just an example, more combinations are tested in StringUtilTest - val (state, _) = Arrangement() - .withText(messageText) - .arrange() + val (state, arrangement) = Arrangement().arrange() // When - state.toComposing() + state.setFocused() // Then - state.inputType.shouldBeInstanceOf().let { - it.isSendButtonEnabled shouldBeEqualTo false + state.inputFocused shouldBeEqualTo true + verify(exactly = 1) { + arrangement.softwareKeyboardController.show() } } @Test - fun `given non-empty text, when composing, then send button should be enabled`() = runTest { + fun `given options are visible when focus is requested then options remain visible and inputFocused is true`() = runTest { // Given - val messageText = "text" - val (state, _) = Arrangement() - .withText(messageText) - .arrange() + val (state, _) = Arrangement().arrange() + state.showAttachments(true) // When - state.toComposing() + state.requestFocus() // Then - state.inputType.shouldBeInstanceOf().let { - it.isSendButtonEnabled shouldBeEqualTo true - } + state.inputFocused shouldBeEqualTo true + state.optionsVisible shouldBeEqualTo true } @Test - fun `given empty text, when editing, then send button should be disabled`() = runTest { + fun `given text is initially blank when transitioning to composing state then send button is disabled`() = runTest { // Given - val editMessageText = "edit" - val messageText = String.EMPTY - val (state, _) = Arrangement() - .withText(messageText) - .arrange() + val (state, _) = Arrangement().withText("").arrange() // When - state.toEdit(editMessageText) + state.toComposing() // Then - state.inputType.shouldBeInstanceOf().let { - it.isEditButtonEnabled shouldBeEqualTo false - } + state.inputType.shouldBeInstanceOf().isSendButtonEnabled shouldBeEqualTo false } @Test - fun `given non-empty text bit with only empty markdown, when editing, then send button should be disabled`() = runTest { + fun `given unchanged text when editing then edit button is disabled`() = runTest { // Given - val editMessageText = "edit" - val messageText = "# " // just an example, more combinations are tested in StringUtilTest - val (state, _) = Arrangement() - .withText(messageText) - .arrange() + val messageText = "Hello" + val (state, _) = Arrangement().withText(messageText).arrange() // When - state.toEdit(editMessageText) + state.toEdit(messageText) // Then - state.inputType.shouldBeInstanceOf().let { - it.isEditButtonEnabled shouldBeEqualTo false - } + state.inputType.shouldBeInstanceOf().isEditButtonEnabled shouldBeEqualTo false } @Test - fun `given the same text as edit message text, when editing, then send button should be disabled`() = runTest { - // Given - val editMessageText = "edit" - val (state, _) = Arrangement() - .withText(editMessageText) - .arrange() + fun `given additional space is added to the keyboard when handling IME offset change then options height adjusts but keyboard height remains`() = + runTest { + // Given + val (state, _) = Arrangement().arrange() + state.updateValuesForTesting(keyboardHeight = 20.dp) - // When - state.toEdit(editMessageText) + // When + state.handleImeOffsetChange(50.dp, 0.dp, 50.dp, 50.dp) - // Then - state.inputType.shouldBeInstanceOf().let { - it.isEditButtonEnabled shouldBeEqualTo false + // Then + state.keyboardHeight shouldBeEqualTo 20.dp + state.optionsHeight shouldBeEqualTo 50.dp } - } @Test - fun `given different text than edit message text, when editing, then send button should be enabled`() = runTest { + fun `given keyboard is visible when keyboard is hidden then reset keyboard and options height`() = runTest { // Given - val editMessageText = "edit" - val messageText = "$editMessageText new" - val (state, _) = Arrangement() - .withText(messageText) - .arrange() + val (state, _) = Arrangement().arrange() + state.updateValuesForTesting(keyboardHeight = 30.dp, optionsHeight = 30.dp) // When - state.toEdit(editMessageText) + state.handleImeOffsetChange(0.dp, 0.dp, 30.dp, 0.dp) // Then - state.inputType.shouldBeInstanceOf().let { - it.isEditButtonEnabled shouldBeEqualTo true - } + state.keyboardHeight shouldBeEqualTo 30.dp + state.optionsHeight shouldBeEqualTo 30.dp + state.inputFocused shouldBeEqualTo false } class Arrangement { private val textFieldState = TextFieldState() - private val state = MessageCompositionInputStateHolder(textFieldState) + + val softwareKeyboardController = mockk() + + private val focusRequester = mockk() + + private val state by lazy { + MessageCompositionInputStateHolder(textFieldState, softwareKeyboardController, focusRequester) + } + + init { + MockKAnnotations.init(this, relaxUnitFun = true) + every { focusRequester.requestFocus() } returns Unit + every { focusRequester.captureFocus() } returns true + every { softwareKeyboardController.show() } returns Unit + } fun withText(text: String) = apply { textFieldState.setTextAndPlaceCursorAtEnd(text) @@ -414,11 +256,4 @@ class MessageCompositionInputStateHolderTest { fun arrange() = state to this } - - companion object { - // I set it 0 to make tests more straight forward - val NAVIGATION_BAR_HEIGHT = 0.dp - val SOURCE = 0.dp - val TARGET = 50.dp - } } diff --git a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/bottomsheet/WireModalSheetLayout.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/bottomsheet/WireModalSheetLayout.kt index 9177d70f792..7b2aa9e0ff2 100644 --- a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/bottomsheet/WireModalSheetLayout.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/bottomsheet/WireModalSheetLayout.kt @@ -21,10 +21,13 @@ package com.wire.android.ui.common.bottomsheet import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.absoluteOffset +import androidx.compose.foundation.layout.navigationBars import androidx.compose.material3.BottomSheetDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.ModalBottomSheetProperties import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -42,6 +45,9 @@ fun WireModalSheetLayout( contentColor: Color = WireBottomSheetDefaults.WireSheetContentColor, tonalElevation: Dp = WireBottomSheetDefaults.WireSheetTonalElevation, scrimColor: Color = BottomSheetDefaults.ScrimColor, + onBackPress: (() -> Unit) = { sheetState.hide() }, + onDismissRequest: (() -> Unit) = sheetState::onDismissRequest, + shouldDismissOnBackPress: Boolean = true, dragHandle: @Composable (() -> Unit)? = { WireBottomSheetDefaults.WireDragHandle() }, sheetContent: @Composable ColumnScope.(T) -> Unit ) { @@ -49,19 +55,23 @@ fun WireModalSheetLayout( ModalBottomSheet( sheetState = sheetState.sheetState, shape = sheetShape, - content = { sheetContent(expandedValue.value) }, + content = { + BackHandler(!shouldDismissOnBackPress) { + onBackPress() + } + sheetContent(expandedValue.value) + }, containerColor = containerColor, contentColor = contentColor, scrimColor = scrimColor, tonalElevation = tonalElevation, - onDismissRequest = sheetState::onDismissRequest, + onDismissRequest = onDismissRequest, dragHandle = dragHandle, - modifier = modifier.absoluteOffset(y = 1.dp) + modifier = modifier.absoluteOffset(y = 1.dp), + contentWindowInsets = { WindowInsets.navigationBars }, + properties = ModalBottomSheetProperties(shouldDismissOnBackPress = shouldDismissOnBackPress) ) } - BackHandler(enabled = sheetState.isVisible) { - sheetState.hide() - } } @Composable diff --git a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/bottomsheet/WireModalSheetState.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/bottomsheet/WireModalSheetState.kt index afe962eee2a..a43ddb14322 100644 --- a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/bottomsheet/WireModalSheetState.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/bottomsheet/WireModalSheetState.kt @@ -28,16 +28,20 @@ import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.SoftwareKeyboardController import androidx.compose.ui.unit.Density import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) open class WireModalSheetState internal constructor( density: Density, private val scope: CoroutineScope, - initialValue: WireSheetValue = WireSheetValue.Hidden, - private val onDismissAction: () -> Unit = {} + private val keyboardController: SoftwareKeyboardController? = null, + private val onDismissAction: () -> Unit = {}, + initialValue: WireSheetValue = WireSheetValue.Hidden ) { val sheetState: SheetState = SheetState( density = density, @@ -50,8 +54,15 @@ open class WireModalSheetState internal constructor( var currentValue: WireSheetValue by mutableStateOf(initialValue) private set - fun show(value: T) { - currentValue = WireSheetValue.Expanded(value) + fun show(value: T, hideKeyboard: Boolean = false) { + scope.launch { + // workaround for jumping bottom sheet when keyboard hides + if (hideKeyboard && keyboardController != null) { + keyboardController.hide() + delay(DELAY_TO_SHOW_BOTTOM_SHEET_WHEN_KEYBOARD_IS_OPEN) + } + currentValue = WireSheetValue.Expanded(value) + } } fun hide(onComplete: suspend () -> Unit = {}) = scope.launch { @@ -59,6 +70,7 @@ open class WireModalSheetState internal constructor( currentValue = WireSheetValue.Hidden onComplete() } + fun hide() = hide {} fun onDismissRequest() { @@ -70,8 +82,15 @@ open class WireModalSheetState internal constructor( get() = currentValue !is WireSheetValue.Hidden companion object { + const val DELAY_TO_SHOW_BOTTOM_SHEET_WHEN_KEYBOARD_IS_OPEN = 300L + @Suppress("UNCHECKED_CAST") - fun saver(density: Density, scope: CoroutineScope): Saver, *> = Saver( + fun saver( + density: Density, + softwareKeyboardController: SoftwareKeyboardController?, + onDismissAction: () -> Unit, + scope: CoroutineScope + ): Saver, *> = Saver( save = { val isExpanded = it.currentValue is WireSheetValue.Expanded val (isValueOfTypeUnit, value) = (it.currentValue as? WireSheetValue.Expanded)?.let { @@ -93,9 +112,10 @@ open class WireModalSheetState internal constructor( WireSheetValue.Expanded(value) } } + false -> WireSheetValue.Hidden } - WireModalSheetState(density, scope, sheetValue) + WireModalSheetState(density, scope, softwareKeyboardController, onDismissAction, sheetValue) } ) } @@ -119,12 +139,26 @@ fun rememberWireModalSheetState( initialValue: WireSheetValue = WireSheetValue.Hidden, onDismissAction: () -> Unit = {} ): WireModalSheetState { + val softwareKeyboardController = LocalSoftwareKeyboardController.current val density = LocalDensity.current val scope = rememberCoroutineScope() - return rememberSaveable(saver = WireModalSheetState.saver(density, scope)) { - WireModalSheetState(density, scope, initialValue, onDismissAction) + return rememberSaveable( + saver = WireModalSheetState.saver( + density = density, + softwareKeyboardController = softwareKeyboardController, + onDismissAction = onDismissAction, + scope = scope + ) + ) { + WireModalSheetState( + density = density, + scope = scope, + keyboardController = softwareKeyboardController, + onDismissAction = onDismissAction, + initialValue = initialValue + ) } } // to simplify execution of the sheet with Unit value -fun WireModalSheetState.show() = this.show(Unit) +fun WireModalSheetState.show(hideKeyboard: Boolean = false) = this.show(Unit, hideKeyboard = hideKeyboard) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index dbe8efb65bc..393c0b5b77a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -63,6 +63,7 @@ hilt-work = "1.2.0" # Android UI accompanist = "0.32.0" # adjusted to work with compose-destinations "1.9.54" material = "1.12.0" +material3 = "1.3.0-rc01" coil = "2.6.0" commonmark = "0.22.0" @@ -199,7 +200,7 @@ compose-material-android = { module = "androidx.compose.material:material-androi compose-material-core = { module = "androidx.compose.material:material" } compose-material-icons = { module = "androidx.compose.material:material-icons-extended" } compose-material-ripple = { module = "androidx.compose.material:material-ripple" } -compose-material3 = { module = "androidx.compose.material3:material3" } +compose-material3 = { module = "androidx.compose.material3:material3", version.ref = "material3" } compose-runtime-liveData = { module = "androidx.compose.runtime:runtime-livedata" } compose-ui = { module = "androidx.compose.ui:ui" } compose-ui-test-junit = { module = "androidx.compose.ui:ui-test-junit4" } diff --git a/kalium b/kalium index ad1bbe20952..147eb18545e 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit ad1bbe209520ce6ef9454316e8d1cb8ca7cb84c6 +Subproject commit 147eb18545ea96053ee2df076338f809d211290d