diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ae26201ea83..e3f131194d9 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -135,6 +135,7 @@ dependencies { implementation(libs.compose.ui) implementation(libs.compose.foundation) + implementation(libs.compose.material.android) // we still cannot get rid of material2 because swipeable is still missing - https://issuetracker.google.com/issues/229839039 // https://developer.android.com/jetpack/compose/designsystems/material2-material3#components-and implementation(libs.compose.material.core) diff --git a/app/src/main/kotlin/com/wire/android/ui/authentication/create/details/CreateAccountDetailsScreen.kt b/app/src/main/kotlin/com/wire/android/ui/authentication/create/details/CreateAccountDetailsScreen.kt index 03d9332dac8..6fbbce6e6e6 100644 --- a/app/src/main/kotlin/com/wire/android/ui/authentication/create/details/CreateAccountDetailsScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/authentication/create/details/CreateAccountDetailsScreen.kt @@ -24,18 +24,15 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material3.MaterialTheme -import com.wire.android.ui.common.scaffold.WireScaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester @@ -122,7 +119,6 @@ fun CreateAccountDetailsScreen( } } -@OptIn(ExperimentalComposeUiApi::class) @Composable private fun DetailsContent( state: CreateAccountDetailsViewState, @@ -259,7 +255,7 @@ private fun DetailsContent( labelText = stringResource(R.string.create_account_details_confirm_password_label), labelMandatoryIcon = true, imeAction = ImeAction.Done, - keyboardActions = KeyboardActions(onDone = { keyboardController?.hide() }), + onImeAction = { keyboardController?.hide() }, modifier = Modifier .padding( horizontal = MaterialTheme.wireDimensions.spacing16x, diff --git a/app/src/main/kotlin/com/wire/android/ui/authentication/devices/register/RegisterDeviceScreen.kt b/app/src/main/kotlin/com/wire/android/ui/authentication/devices/register/RegisterDeviceScreen.kt index ba16c4bb82c..9b53a3d154c 100644 --- a/app/src/main/kotlin/com/wire/android/ui/authentication/devices/register/RegisterDeviceScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/authentication/devices/register/RegisterDeviceScreen.kt @@ -24,13 +24,10 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.text.KeyboardActions import androidx.compose.material3.MaterialTheme -import com.wire.android.ui.common.scaffold.WireScaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.testTag @@ -55,6 +52,7 @@ import com.wire.android.ui.common.button.WirePrimaryButton import com.wire.android.ui.common.dialogs.CancelLoginDialogContent import com.wire.android.ui.common.dialogs.CancelLoginDialogState import com.wire.android.ui.common.error.CoreFailureErrorDialog +import com.wire.android.ui.common.scaffold.WireScaffold import com.wire.android.ui.common.textfield.WirePasswordTextField import com.wire.android.ui.common.textfield.WireTextFieldState import com.wire.android.ui.common.textfield.clearAutofillTree @@ -183,7 +181,6 @@ private fun RegisterDeviceContent( } } -@OptIn(ExperimentalComposeUiApi::class) @Composable private fun PasswordTextField(state: RegisterDeviceState, onPasswordChange: (TextFieldValue) -> Unit) { val keyboardController = LocalSoftwareKeyboardController.current @@ -197,7 +194,7 @@ private fun PasswordTextField(state: RegisterDeviceState, onPasswordChange: (Tex else -> WireTextFieldState.Default }, imeAction = ImeAction.Done, - keyboardActions = KeyboardActions(onDone = { keyboardController?.hide() }), + onImeAction = { keyboardController?.hide() }, modifier = Modifier .padding(horizontal = MaterialTheme.wireDimensions.spacing16x) .testTag("password field"), diff --git a/app/src/main/kotlin/com/wire/android/ui/authentication/devices/remove/RemoveDeviceDialog.kt b/app/src/main/kotlin/com/wire/android/ui/authentication/devices/remove/RemoveDeviceDialog.kt index bcd7afae6c4..2063d3f6520 100644 --- a/app/src/main/kotlin/com/wire/android/ui/authentication/devices/remove/RemoveDeviceDialog.kt +++ b/app/src/main/kotlin/com/wire/android/ui/authentication/devices/remove/RemoveDeviceDialog.kt @@ -18,12 +18,10 @@ package com.wire.android.ui.authentication.devices.remove import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.text.KeyboardActions import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester @@ -43,7 +41,6 @@ import com.wire.android.ui.common.textfield.WireTextFieldState import com.wire.android.ui.theme.wireDimensions import com.wire.android.util.deviceDateTimeFormat -@OptIn(ExperimentalComposeUiApi::class) @Composable fun RemoveDeviceDialog( errorState: RemoveDeviceError, @@ -97,7 +94,7 @@ fun RemoveDeviceDialog( else -> WireTextFieldState.Default }, imeAction = ImeAction.Done, - keyboardActions = KeyboardActions(onDone = { keyboardController?.hide() }), + onImeAction = { keyboardController?.hide() }, modifier = Modifier .focusRequester(focusRequester) .padding(bottom = MaterialTheme.wireDimensions.spacing8x) diff --git a/app/src/main/kotlin/com/wire/android/ui/authentication/login/email/LoginEmailScreen.kt b/app/src/main/kotlin/com/wire/android/ui/authentication/login/email/LoginEmailScreen.kt index f03624ae095..3c7d851c1b9 100644 --- a/app/src/main/kotlin/com/wire/android/ui/authentication/login/email/LoginEmailScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/authentication/login/email/LoginEmailScreen.kt @@ -29,7 +29,6 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material3.MaterialTheme @@ -41,7 +40,6 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier -import androidx.compose.ui.autofill.AutofillType import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.testTag @@ -62,8 +60,9 @@ import com.wire.android.ui.common.button.WireButtonState import com.wire.android.ui.common.button.WirePrimaryButton import com.wire.android.ui.common.colorsScheme import com.wire.android.ui.common.rememberBottomBarElevationState -import com.wire.android.ui.common.textfield.AutoFillTextField +import com.wire.android.ui.common.textfield.WireAutoFillType import com.wire.android.ui.common.textfield.WirePasswordTextField +import com.wire.android.ui.common.textfield.WireTextField import com.wire.android.ui.common.textfield.WireTextFieldState import com.wire.android.ui.common.textfield.clearAutofillTree import com.wire.android.ui.theme.WireTheme @@ -207,7 +206,6 @@ private fun LoginEmailContent( } } -@OptIn(ExperimentalComposeUiApi::class) @Composable private fun UserIdentifierInput( modifier: Modifier, @@ -216,8 +214,8 @@ private fun UserIdentifierInput( onUserIdentifierChange: (TextFieldValue) -> Unit, isEnabled: Boolean, ) { - AutoFillTextField( - autofillTypes = listOf(AutofillType.EmailAddress, AutofillType.Username), + WireTextField( + autoFillType = WireAutoFillType.Login, value = userIdentifier, onValueChange = onUserIdentifierChange, placeholderText = stringResource(R.string.login_user_identifier_placeholder), @@ -233,7 +231,6 @@ private fun UserIdentifierInput( ) } -@OptIn(ExperimentalComposeUiApi::class) @Composable private fun PasswordInput(modifier: Modifier, password: TextFieldValue, onPasswordChange: (TextFieldValue) -> Unit) { val keyboardController = LocalSoftwareKeyboardController.current @@ -241,7 +238,7 @@ private fun PasswordInput(modifier: Modifier, password: TextFieldValue, onPasswo value = password, onValueChange = onPasswordChange, imeAction = ImeAction.Done, - keyboardActions = KeyboardActions(onDone = { keyboardController?.hide() }), + onImeAction = { keyboardController?.hide() }, modifier = modifier.testTag("passwordField"), autofill = true, testTag = "PasswordInput" diff --git a/app/src/main/kotlin/com/wire/android/ui/authentication/login/email/ProxyScreen.kt b/app/src/main/kotlin/com/wire/android/ui/authentication/login/email/ProxyScreen.kt index 1038d6c276b..d6ff525d686 100644 --- a/app/src/main/kotlin/com/wire/android/ui/authentication/login/email/ProxyScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/authentication/login/email/ProxyScreen.kt @@ -22,13 +22,11 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.testTag @@ -139,7 +137,6 @@ private fun ProxyIdentifierInput( ) } -@OptIn(ExperimentalComposeUiApi::class) @Composable private fun ProxyPasswordInput(modifier: Modifier, proxyPassword: TextFieldValue, onProxyPasswordChange: (TextFieldValue) -> Unit) { val keyboardController = LocalSoftwareKeyboardController.current @@ -148,7 +145,7 @@ private fun ProxyPasswordInput(modifier: Modifier, proxyPassword: TextFieldValue onValueChange = onProxyPasswordChange, imeAction = ImeAction.Done, labelText = stringResource(R.string.label_proxy_password), - keyboardActions = KeyboardActions(onDone = { keyboardController?.hide() }), + onImeAction = { keyboardController?.hide() }, modifier = modifier.testTag("passwordField"), autofill = false ) diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/controlbuttons/CameraButton.kt b/app/src/main/kotlin/com/wire/android/ui/calling/controlbuttons/CameraButton.kt index aec1317a0fa..472756b7e71 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/controlbuttons/CameraButton.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/controlbuttons/CameraButton.kt @@ -22,7 +22,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.wrapContentSize -import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material.ripple import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.runtime.remember @@ -62,7 +62,7 @@ fun CameraButton( .wrapContentSize() .clickable( interactionSource = remember { MutableInteractionSource() }, - indication = rememberRipple( + indication = ripple( bounded = false, radius = dimensions().defaultCallingControlsSize / 2 ), diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/controlbuttons/CameraFlipButton.kt b/app/src/main/kotlin/com/wire/android/ui/calling/controlbuttons/CameraFlipButton.kt index 29c06df9314..5b8920776e1 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/controlbuttons/CameraFlipButton.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/controlbuttons/CameraFlipButton.kt @@ -21,7 +21,7 @@ package com.wire.android.ui.calling.controlbuttons import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.wrapContentSize -import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material.ripple import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.runtime.remember @@ -46,7 +46,7 @@ fun CameraFlipButton( .wrapContentSize() .clickable( interactionSource = remember { MutableInteractionSource() }, - indication = rememberRipple(bounded = false, radius = dimensions().defaultCallingControlsSize / 2), + indication = ripple(bounded = false, radius = dimensions().defaultCallingControlsSize / 2), role = Role.Button, onClick = onCameraFlipButtonClicked diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/controlbuttons/DeclineButton.kt b/app/src/main/kotlin/com/wire/android/ui/calling/controlbuttons/DeclineButton.kt index d3a34db51bd..696d3602633 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/controlbuttons/DeclineButton.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/controlbuttons/DeclineButton.kt @@ -21,7 +21,7 @@ package com.wire.android.ui.calling.controlbuttons import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.size -import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material.ripple import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.runtime.Composable @@ -45,7 +45,7 @@ fun DeclineButton(buttonClicked: () -> Unit) { modifier = Modifier .clickable( interactionSource = remember { MutableInteractionSource() }, - indication = rememberRipple( + indication = ripple( bounded = false, radius = dimensions().outgoingCallHangUpButtonSize / 2 ), diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/controlbuttons/MicrophoneButton.kt b/app/src/main/kotlin/com/wire/android/ui/calling/controlbuttons/MicrophoneButton.kt index 5eac7fbd901..a19c42d80b1 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/controlbuttons/MicrophoneButton.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/controlbuttons/MicrophoneButton.kt @@ -22,7 +22,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.wrapContentSize -import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material.ripple import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.runtime.remember @@ -49,7 +49,7 @@ fun MicrophoneButton( .wrapContentSize() .clickable( interactionSource = remember { MutableInteractionSource() }, - indication = rememberRipple(bounded = false, radius = dimensions().defaultCallingControlsSize / 2), + indication = ripple(bounded = false, radius = dimensions().defaultCallingControlsSize / 2), role = Role.Button, onClick = { onMicrophoneButtonClicked() } ), diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/controlbuttons/SpeakerButton.kt b/app/src/main/kotlin/com/wire/android/ui/calling/controlbuttons/SpeakerButton.kt index 91a0949c9ce..0771baf44cc 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/controlbuttons/SpeakerButton.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/controlbuttons/SpeakerButton.kt @@ -22,12 +22,11 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.wrapContentSize -import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material.ripple import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role @@ -50,7 +49,7 @@ fun SpeakerButton( .wrapContentSize() .clickable( interactionSource = remember { MutableInteractionSource() }, - indication = rememberRipple(bounded = false, radius = dimensions().defaultCallingControlsSize / 2), + indication = ripple(bounded = false, radius = dimensions().defaultCallingControlsSize / 2), role = Role.Button, onClick = { onSpeakerButtonClicked() } ), diff --git a/app/src/main/kotlin/com/wire/android/ui/common/AppExtensions.kt b/app/src/main/kotlin/com/wire/android/ui/common/AppExtensions.kt index dab64da1e8e..6272bc25548 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/AppExtensions.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/AppExtensions.kt @@ -21,7 +21,7 @@ package com.wire.android.ui.common import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material.ripple import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.State @@ -62,7 +62,7 @@ fun Modifier.selectableBackground(isSelected: Boolean, onClick: () -> Unit): Mod selected = isSelected, onClick = { onClick() }, interactionSource = remember { MutableInteractionSource() }, - indication = rememberRipple(bounded = true, color = MaterialTheme.colorScheme.onBackground.copy(0.5f)), + indication = ripple(bounded = true, color = MaterialTheme.colorScheme.onBackground.copy(0.5f)), role = Role.Tab ) diff --git a/app/src/main/kotlin/com/wire/android/ui/common/textfield/AutoFillTextField.kt b/app/src/main/kotlin/com/wire/android/ui/common/textfield/AutoFillTextField.kt deleted file mode 100644 index a7a0058505f..00000000000 --- a/app/src/main/kotlin/com/wire/android/ui/common/textfield/AutoFillTextField.kt +++ /dev/null @@ -1,135 +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.ui.common.textfield - -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.Modifier -import androidx.compose.ui.autofill.Autofill -import androidx.compose.ui.autofill.AutofillNode -import androidx.compose.ui.autofill.AutofillType -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.layout.boundsInWindow -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.platform.LocalAutofill -import androidx.compose.ui.platform.LocalAutofillTree -import androidx.compose.ui.text.TextRange -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.text.input.VisualTransformation -import androidx.compose.ui.unit.Dp -import com.wire.android.ui.theme.wireDimensions -import com.wire.android.ui.theme.wireTypography -import com.wire.android.util.EMPTY - -@OptIn(ExperimentalComposeUiApi::class) -@Composable -internal fun AutoFillTextField( - autofillTypes: List, - value: TextFieldValue, - onValueChange: (TextFieldValue) -> Unit, - readOnly: Boolean = false, - singleLine: Boolean = true, - maxLines: Int = 1, - keyboardOptions: KeyboardOptions = KeyboardOptions.Default, - keyboardActions: KeyboardActions = KeyboardActions(), - leadingIcon: @Composable (() -> Unit)? = null, - trailingIcon: @Composable (() -> Unit)? = null, - placeholderText: String? = null, - labelText: String? = null, - labelMandatoryIcon: Boolean = false, - descriptionText: String? = null, - state: WireTextFieldState = WireTextFieldState.Default, - interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, - visualTransformation: VisualTransformation = VisualTransformation.None, - textStyle: TextStyle = MaterialTheme.wireTypography.body01, - placeholderTextStyle: TextStyle = MaterialTheme.wireTypography.body01, - inputMinHeight: Dp = MaterialTheme.wireDimensions.textFieldMinHeight, - shape: Shape = RoundedCornerShape(MaterialTheme.wireDimensions.textFieldCornerSize), - colors: WireTextFieldColors = wireTextFieldColors(), - modifier: Modifier = Modifier, - onTap: (Offset) -> Unit = { }, - testTag: String = String.EMPTY -) { - val autofillNode = AutofillNode( - autofillTypes = autofillTypes, - onFill = { onValueChange(TextFieldValue(it, TextRange(it.length))) } - ) - val autofill = LocalAutofill.current - - LocalAutofillTree.current += autofillNode - - WireTextField( - value = value, - onValueChange = onValueChange, - readOnly = readOnly, - singleLine = singleLine, - maxLines = maxLines, - keyboardOptions = keyboardOptions, - keyboardActions = keyboardActions, - leadingIcon = leadingIcon, - trailingIcon = trailingIcon, - placeholderText = placeholderText, - labelText = labelText, - labelMandatoryIcon = labelMandatoryIcon, - descriptionText = descriptionText, - state = state, - interactionSource = interactionSource, - visualTransformation = visualTransformation, - textStyle = textStyle, - placeholderTextStyle = placeholderTextStyle, - inputMinHeight = inputMinHeight, - shape = shape, - colors = colors, - modifier = modifier - .fillBounds(autofillNode) - .defaultOnFocusAutoFill(autofill, autofillNode), - onTap = onTap, - testTag = testTag - ) -} - -@OptIn(ExperimentalComposeUiApi::class) -fun Modifier.fillBounds(autofillNode: AutofillNode) = this.then( - Modifier.onGloballyPositioned { autofillNode.boundingBox = it.boundsInWindow() } -) - -@OptIn(ExperimentalComposeUiApi::class) -fun Modifier.defaultOnFocusAutoFill(autofill: Autofill?, autofillNode: AutofillNode): Modifier = - then(Modifier.onFocusChanged { focusState -> - if (focusState.isFocused) { - autofill?.requestAutofillForNode(autofillNode) - } else { - autofill?.cancelAutofillForNode(autofillNode) - } - }) - -@OptIn(ExperimentalComposeUiApi::class) -@Composable -fun clearAutofillTree() { - LocalAutofillTree.current.children.clear() -} diff --git a/app/src/main/kotlin/com/wire/android/ui/common/textfield/CodeTextField.kt b/app/src/main/kotlin/com/wire/android/ui/common/textfield/CodeTextField.kt index 960b62db982..a4808848f06 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/textfield/CodeTextField.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/textfield/CodeTextField.kt @@ -18,58 +18,38 @@ package com.wire.android.ui.common.textfield -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.interaction.FocusInteraction -import androidx.compose.foundation.interaction.Interaction -import androidx.compose.foundation.interaction.InteractionSource +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.IntrinsicSize -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.InputTransformation +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Shape import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.integerResource -import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp import com.wire.android.R +import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireDimensions import com.wire.android.ui.theme.wireTypography -import com.wire.android.util.EMPTY -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flow -import java.lang.Integer.min +import com.wire.android.util.ui.PreviewMultipleThemes -@OptIn(ExperimentalComposeUiApi::class) @Composable fun CodeTextField( + textState: TextFieldState, codeLength: Int = integerResource(id = R.integer.code_length), - value: TextFieldValue, - onValueChange: (CodeFieldValue) -> Unit, shape: Shape = RoundedCornerShape(MaterialTheme.wireDimensions.corner4x), colors: WireTextFieldColors = wireTextFieldColors(), textStyle: TextStyle = MaterialTheme.wireTypography.code01, @@ -81,101 +61,94 @@ fun CodeTextField( ) { val keyboardController = LocalSoftwareKeyboardController.current val enabled = state !is WireTextFieldState.Disabled - Column( + CodeTextFieldLayout( + textState = textState, + codeLength = codeLength, + shape = shape, + colors = colors, + textStyle = textStyle, + state = state, + maxHorizontalSpacing = maxHorizontalSpacing, horizontalAlignment = horizontalAlignment, - modifier = modifier.width(IntrinsicSize.Min), - ) { - BasicTextField( - value = value, - onValueChange = { - val textDigits = it.text.filter { it.isDigit() } // don't allow characters other than digits to be entered - .let { it.substring(0, min(codeLength, it.length)) } // don't allow more digits than required - onValueChange( - CodeFieldValue( - text = TextFieldValue(text = textDigits, selection = TextRange(textDigits.length)), - isFullyFilled = textDigits.length == codeLength - ) - ) - }, - enabled = enabled, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, autoCorrect = false, imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { keyboardController?.hide() }), - interactionSource = interactionSource, - decorationBox = { - Row(horizontalArrangement = Arrangement.SpaceBetween) { - repeat(codeLength) { index -> - if (index != 0) Spacer(modifier = Modifier - .weight(1f, fill = false) - .width(maxHorizontalSpacing)) - Digit( - char = value.text.getOrNull(index), - shape = shape, - colors = colors, - textStyle = textStyle, - selected = index == value.text.length, - state = state - ) - } - } - }) - val bottomText = when { - state is WireTextFieldState.Error && state.errorText != null -> state.errorText - else -> String.EMPTY - } - AnimatedVisibility(visible = bottomText.isNotEmpty()) { - Text( - text = bottomText, - style = MaterialTheme.wireTypography.label04, - color = colors.descriptionColor(state).value, - modifier = Modifier - .padding(top = MaterialTheme.wireDimensions.spacing4x) + modifier = modifier, + innerBasicTextField = { decorator, textFieldModifier -> + BasicTextField( + state = textState, + textStyle = textStyle, + enabled = enabled, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, autoCorrect = false, imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { keyboardController?.hide() }), + interactionSource = interactionSource, + inputTransformation = InputTransformation.maxLengthDigits(codeLength), + decorator = decorator, + modifier = textFieldModifier, ) } - } + ) } +/* +TODO: BasicTextField2 (value, onValueChange) overload is removed completely in compose foundation 1.7.0, + for now we can use our custom StateSyncingModifier to sync TextFieldValue with TextFieldState, + but eventually we should migrate and remove this function when all usages are replaced with the TextFieldState. +*/ +@Deprecated("Use the new one with TextFieldState.") @Composable -private fun Digit( - char: Char? = null, - shape: Shape = RoundedCornerShape(MaterialTheme.wireDimensions.textFieldCornerSize), +fun CodeTextField( + codeLength: Int = integerResource(id = R.integer.code_length), + value: TextFieldValue, + onValueChange: (CodeFieldValue) -> Unit, + shape: Shape = RoundedCornerShape(MaterialTheme.wireDimensions.corner4x), colors: WireTextFieldColors = wireTextFieldColors(), - textStyle: TextStyle = MaterialTheme.wireTypography.body01, + textStyle: TextStyle = MaterialTheme.wireTypography.code01, state: WireTextFieldState = WireTextFieldState.Default, - selected: Boolean = false + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + maxHorizontalSpacing: Dp = MaterialTheme.wireDimensions.spacing16x, + horizontalAlignment: Alignment.Horizontal = Alignment.CenterHorizontally, + modifier: Modifier = Modifier ) { - val interactionSource = object : InteractionSource { - private val focusInteraction: FocusInteraction.Focus = FocusInteraction.Focus() - override val interactions: Flow = flow { - emit(if(selected) focusInteraction else FocusInteraction.Unfocus(focusInteraction)) + val keyboardController = LocalSoftwareKeyboardController.current + val enabled = state !is WireTextFieldState.Disabled + val textState = remember { TextFieldState(value.text, value.selection) } + val onValueChanged: (TextFieldValue) -> Unit = { onValueChange(CodeFieldValue(it, it.text.length == codeLength)) } + CodeTextFieldLayout( + textState = textState, + codeLength = codeLength, + shape = shape, + colors = colors, + textStyle = textStyle, + state = state, + maxHorizontalSpacing = maxHorizontalSpacing, + horizontalAlignment = horizontalAlignment, + modifier = modifier, + innerBasicTextField = { decorator, textFieldModifier -> + BasicTextField( + state = textState, + textStyle = textStyle, + enabled = enabled, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, autoCorrect = false, imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { keyboardController?.hide() }), + interactionSource = interactionSource, + inputTransformation = MaxLengthDigitsFilter(codeLength), + decorator = decorator, + modifier = textFieldModifier.then(StateSyncingModifier(textState, value, onValueChanged)), + ) } - } - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - modifier = Modifier - .background(color = colors.backgroundColor(state).value, shape = shape) - .border(width = 1.dp, color = colors.borderColor(state, interactionSource).value, shape = shape) - .size(width = MaterialTheme.wireDimensions.codeFieldItemWidth, height = MaterialTheme.wireDimensions.codeFieldItemHeight) - ) { - Text( - text = char?.toString() ?: "", - color = colors.textColor(state = state).value, - style = textStyle, - textAlign = TextAlign.Center, - ) - } + ) } data class CodeFieldValue(val text: TextFieldValue, val isFullyFilled: Boolean) -@Preview(name = "Success CodeTextField") +@OptIn(ExperimentalFoundationApi::class) +@PreviewMultipleThemes @Composable -fun PreviewCodeTextFieldSuccess() { - CodeTextField(value = TextFieldValue("123"), onValueChange = {}) +fun PreviewCodeTextFieldSuccess() = WireTheme { + CodeTextField(textState = rememberTextFieldState("123")) } -@Preview(name = "Error CodeTextField") +@OptIn(ExperimentalFoundationApi::class) +@PreviewMultipleThemes @Composable -fun PreviewCodeTextFieldError() { - CodeTextField(value = TextFieldValue("123"), onValueChange = {}, state = WireTextFieldState.Error("error text")) +fun PreviewCodeTextFieldError() = WireTheme { + CodeTextField(textState = rememberTextFieldState("123"), state = WireTextFieldState.Error("error text")) } diff --git a/app/src/main/kotlin/com/wire/android/ui/common/textfield/CodeTextFieldLayout.kt b/app/src/main/kotlin/com/wire/android/ui/common/textfield/CodeTextFieldLayout.kt new file mode 100644 index 00000000000..b684a420e4e --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/common/textfield/CodeTextFieldLayout.kt @@ -0,0 +1,156 @@ +/* + * 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.common.textfield + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.interaction.FocusInteraction +import androidx.compose.foundation.interaction.Interaction +import androidx.compose.foundation.interaction.InteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.input.TextFieldDecorator +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.selection.LocalTextSelectionColors +import androidx.compose.foundation.text.selection.TextSelectionColors +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import com.wire.android.ui.common.dimensions +import com.wire.android.ui.theme.wireDimensions +import com.wire.android.ui.theme.wireTypography +import com.wire.android.util.EMPTY +import io.github.esentsov.PackagePrivate +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow + +@OptIn(ExperimentalFoundationApi::class) +@PackagePrivate +@Composable +internal fun CodeTextFieldLayout( + textState: TextFieldState, + codeLength: Int, + shape: Shape = RoundedCornerShape(MaterialTheme.wireDimensions.corner4x), + colors: WireTextFieldColors = wireTextFieldColors(), + textStyle: TextStyle = MaterialTheme.wireTypography.code01, + state: WireTextFieldState = WireTextFieldState.Default, + maxHorizontalSpacing: Dp = MaterialTheme.wireDimensions.spacing16x, + horizontalAlignment: Alignment.Horizontal = Alignment.CenterHorizontally, + modifier: Modifier = Modifier, + innerBasicTextField: InnerBasicTextFieldBuilder, +) { + Column( + horizontalAlignment = horizontalAlignment, + modifier = modifier.width(IntrinsicSize.Min), + ) { + innerBasicTextField( + decorator = TextFieldDecorator { innerTextField -> + // hide the inner text field as we are dwelling the text field ourselves + CompositionLocalProvider(LocalTextSelectionColors.provides(TextSelectionColors(Color.Transparent, Color.Transparent))) { + Box(modifier = Modifier.drawWithContent { }) { + innerTextField() + } + } + + Row(horizontalArrangement = Arrangement.SpaceBetween) { + repeat(codeLength) { index -> + if (index != 0) { + Spacer( + modifier = Modifier + .weight(1f, fill = false) + .width(maxHorizontalSpacing) + ) + } + Digit( + char = textState.text.getOrNull(index), + shape = shape, + colors = colors, + textStyle = textStyle, + selected = index == textState.text.length, + state = state + ) + } + } + }, + textFieldModifier = Modifier, + ) + val bottomText = when { + state is WireTextFieldState.Error && state.errorText != null -> state.errorText + else -> String.EMPTY + } + AnimatedVisibility(visible = bottomText.isNotEmpty()) { + Text( + text = bottomText, + style = MaterialTheme.wireTypography.label04, + color = colors.descriptionColor(state).value, + modifier = Modifier.padding(top = MaterialTheme.wireDimensions.spacing4x) + ) + } + } +} + +@Composable +private fun Digit( + char: Char? = null, + shape: Shape = RoundedCornerShape(MaterialTheme.wireDimensions.textFieldCornerSize), + colors: WireTextFieldColors = wireTextFieldColors(), + textStyle: TextStyle = MaterialTheme.wireTypography.body01, + state: WireTextFieldState = WireTextFieldState.Default, + selected: Boolean = false +) { + val interactionSource = object : InteractionSource { + private val focusInteraction: FocusInteraction.Focus = FocusInteraction.Focus() + override val interactions: Flow = flow { + emit(if (selected) focusInteraction else FocusInteraction.Unfocus(focusInteraction)) + } + } + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier + .background(color = colors.backgroundColor(state).value, shape = shape) + .border(width = dimensions().spacing1x, color = colors.borderColor(state, interactionSource).value, shape = shape) + .size(width = MaterialTheme.wireDimensions.codeFieldItemWidth, height = MaterialTheme.wireDimensions.codeFieldItemHeight) + ) { + Text( + text = char?.toString() ?: "", + color = colors.textColor(state = state).value, + style = textStyle, + textAlign = TextAlign.Center, + ) + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/common/textfield/InputTransformations.kt b/app/src/main/kotlin/com/wire/android/ui/common/textfield/InputTransformations.kt new file mode 100644 index 00000000000..b516d568f38 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/common/textfield/InputTransformations.kt @@ -0,0 +1,48 @@ +/* + * 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/. + */ +@file:Suppress("MatchingDeclarationName") + +package com.wire.android.ui.common.textfield + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.InputTransformation +import androidx.compose.foundation.text.input.TextFieldBuffer +import androidx.compose.foundation.text.input.TextFieldCharSequence +import androidx.compose.foundation.text.input.then +import androidx.compose.runtime.Stable +import androidx.compose.ui.text.input.KeyboardType +import androidx.core.text.isDigitsOnly + +@OptIn(ExperimentalFoundationApi::class) +class MaxLengthDigitsFilter(private val maxLength: Int) : InputTransformation { + override val keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number) + init { + require(maxLength >= 0) { "maxLength must be at least zero, was $maxLength" } + } + override fun transformInput(originalValue: TextFieldCharSequence, valueWithChanges: TextFieldBuffer) { + val newLength = valueWithChanges.length + if (newLength > maxLength || !valueWithChanges.asCharSequence().isDigitsOnly()) { + valueWithChanges.revertAllChanges() + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Stable +fun InputTransformation.maxLengthDigits(maxLength: Int): InputTransformation = this.then(MaxLengthDigitsFilter(maxLength)) diff --git a/app/src/main/kotlin/com/wire/android/ui/common/textfield/Label.kt b/app/src/main/kotlin/com/wire/android/ui/common/textfield/Label.kt new file mode 100644 index 00000000000..f403430259f --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/common/textfield/Label.kt @@ -0,0 +1,68 @@ +/* + * 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.common.textfield + +import androidx.compose.foundation.interaction.InteractionSource +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import com.wire.android.R +import com.wire.android.ui.common.dimensions +import com.wire.android.ui.theme.WireTheme +import com.wire.android.ui.theme.wireTypography +import com.wire.android.util.ui.PreviewMultipleThemes + +@Composable +fun Label( + labelText: String, + labelMandatoryIcon: Boolean = false, + state: WireTextFieldState = WireTextFieldState.Default, + interactionSource: InteractionSource = remember { MutableInteractionSource() }, + colors: WireTextFieldColors = wireTextFieldColors() +) { + Row { + Text( + text = labelText, + style = MaterialTheme.wireTypography.label01, + color = colors.labelColor(state, interactionSource).value, + modifier = Modifier.padding(bottom = dimensions().spacing4x, end = dimensions().spacing4x) + ) + if (labelMandatoryIcon) { + Icon( + imageVector = ImageVector.vectorResource(id = R.drawable.ic_input_mandatory), + tint = colors.labelMandatoryColor(state).value, + contentDescription = "", + modifier = Modifier.padding(top = dimensions().spacing2x) + ) + } + } +} + +@PreviewMultipleThemes +@Composable +fun PreviewLabel() = WireTheme { + Label("Label", true) +} diff --git a/app/src/main/kotlin/com/wire/android/ui/common/textfield/StateSyncingModifier.kt b/app/src/main/kotlin/com/wire/android/ui/common/textfield/StateSyncingModifier.kt new file mode 100644 index 00000000000..cf1cb821d9f --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/common/textfield/StateSyncingModifier.kt @@ -0,0 +1,100 @@ +/* + * 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.common.textfield + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.text.input.TextFieldCharSequence +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.ui.Modifier +import androidx.compose.ui.node.ModifierNodeElement +import androidx.compose.ui.node.ObserverModifierNode +import androidx.compose.ui.node.observeReads +import androidx.compose.ui.platform.InspectorInfo +import androidx.compose.ui.text.input.TextFieldValue +import io.github.esentsov.PackagePrivate + +/** + * Enables us to temporarily still use TextFieldValue and onValueChanged callback instead of TextFieldState directly, + * also allows us to get selection updates as by default BasicTextField2 callback only gives a String without selection. + * TODO: Remove this class once all WireTextField usages are migrated to use TextFieldState. + */ +@PackagePrivate +internal class StateSyncingModifier( + private val state: TextFieldState, + private val value: TextFieldValue, + private val onValueChanged: (TextFieldValue) -> Unit, +) : ModifierNodeElement() { + + override fun create(): StateSyncingModifierNode = StateSyncingModifierNode(state, onValueChanged) + + override fun update(node: StateSyncingModifierNode) { + node.update(value, onValueChanged) + } + + @Suppress("EqualsAlwaysReturnsTrueOrFalse") + override fun equals(other: Any?): Boolean = false + + override fun hashCode(): Int = state.hashCode() + + @Suppress("EmptyFunctionBlock") + override fun InspectorInfo.inspectableProperties() {} +} + +@OptIn(ExperimentalFoundationApi::class) +@PackagePrivate +internal class StateSyncingModifierNode( + private val state: TextFieldState, + private var onValueChanged: (TextFieldValue) -> Unit, +) : Modifier.Node(), ObserverModifierNode { + override val shouldAutoInvalidate: Boolean + get() = false + + fun update(value: TextFieldValue, onValueChanged: (TextFieldValue) -> Unit) { + this.onValueChanged = onValueChanged + if (value.text != state.text.toString() || value.selection != state.text.selection) { + state.edit { + if (value.text != state.text.toString()) { + replace(0, length, value.text) + } + if (value.selection != state.text.selection) { + selection = value.selection + } + } + onValueChanged(value) + } + } + + override fun onAttach() { + observeTextState(fireOnValueChanged = false) + } + + override fun onObservedReadsChanged() { + observeTextState() + } + + private fun observeTextState(fireOnValueChanged: Boolean = true) { + lateinit var text: TextFieldCharSequence + observeReads { + text = state.text + } + if (fireOnValueChanged) { + val newValue = TextFieldValue(text.toString(), text.selection, text.composition) + onValueChanged(newValue) + } + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/common/textfield/WirePasswordTextField.kt b/app/src/main/kotlin/com/wire/android/ui/common/textfield/WirePasswordTextField.kt index b2ffe371fab..a3dcd7ac8b5 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/textfield/WirePasswordTextField.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/textfield/WirePasswordTextField.kt @@ -18,160 +18,178 @@ package com.wire.android.ui.common.textfield +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.ScrollState import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Visibility -import androidx.compose.material.icons.filled.VisibilityOff -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.LocalTextStyle +import androidx.compose.foundation.text.BasicSecureTextField +import androidx.compose.foundation.text.input.InputTransformation +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.TextObfuscationMode +import androidx.compose.foundation.text.input.maxLength +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.autofill.AutofillType import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.platform.testTag +import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import com.wire.android.R +import com.wire.android.ui.theme.WireTheme +import com.wire.android.ui.theme.wireDimensions +import com.wire.android.ui.theme.wireTypography import com.wire.android.util.EMPTY +import com.wire.android.util.ui.PreviewMultipleThemes -@OptIn(ExperimentalComposeUiApi::class) +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun WirePasswordTextField( + textState: TextFieldState, + placeholderText: String? = stringResource(R.string.login_password_placeholder), + labelText: String? = stringResource(R.string.login_password_label), + labelMandatoryIcon: Boolean = false, + descriptionText: String? = null, + state: WireTextFieldState = WireTextFieldState.Default, + autoFill: Boolean = false, + inputTransformation: InputTransformation = InputTransformation.maxLength(8000), + textObfuscationMode: TextObfuscationMode = TextObfuscationMode.RevealLastTyped, + imeAction: ImeAction = ImeAction.Default, + onImeAction: (() -> Unit)? = null, + scrollState: ScrollState = rememberScrollState(), + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + textStyle: TextStyle = MaterialTheme.wireTypography.body01.copy(textAlign = TextAlign.Start), + placeholderTextStyle: TextStyle = MaterialTheme.wireTypography.body01.copy(textAlign = TextAlign.Start), + placeholderAlignment: Alignment.Horizontal = Alignment.Start, + inputMinHeight: Dp = MaterialTheme.wireDimensions.textFieldMinHeight, + shape: Shape = RoundedCornerShape(MaterialTheme.wireDimensions.textFieldCornerSize), + colors: WireTextFieldColors = wireTextFieldColors(), + modifier: Modifier = Modifier, + onTap: ((Offset) -> Unit)? = null, + testTag: String = String.EMPTY, +) { + val autoFillType = if (autoFill) WireAutoFillType.Password else WireAutoFillType.None + WireTextFieldLayout( + shouldShowPlaceholder = textState.text.isEmpty(), + placeholderText = placeholderText, + labelText = labelText, + labelMandatoryIcon = labelMandatoryIcon, + descriptionText = descriptionText, + state = state, + interactionSource = interactionSource, + placeholderTextStyle = placeholderTextStyle, + placeholderAlignment = placeholderAlignment, + inputMinHeight = inputMinHeight, + shape = shape, + colors = colors, + modifier = modifier.autoFill(autoFillType, textState::setTextAndPlaceCursorAtEnd), + testTag = testTag, + onTap = onTap, + innerBasicTextField = { decorator, textFieldModifier -> + BasicSecureTextField( + state = textState, + textStyle = textStyle.copy(color = colors.textColor(state = state).value, textDirection = TextDirection.ContentOrLtr), + imeAction = imeAction, + onSubmit = { onImeAction?.invoke().let { onImeAction != null } }, + inputTransformation = inputTransformation, + textObfuscationMode = textObfuscationMode, + scrollState = scrollState, + enabled = state !is WireTextFieldState.Disabled, + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + interactionSource = interactionSource, + modifier = textFieldModifier, + decorator = decorator, + ) + } + ) +} + +/* +TODO: BasicSecureTextField (value, onValueChange) overload is removed completely in compose foundation 1.7.0, + for now we can use our custom StateSyncingModifier to sync TextFieldValue with TextFieldState, + but eventually we should migrate and remove this function when all usages are replaced with the TextFieldState. +*/ +@Deprecated("Use the new one with TextFieldState.") +@OptIn(ExperimentalFoundationApi::class) @Composable fun WirePasswordTextField( value: TextFieldValue, onValueChange: (TextFieldValue) -> Unit, - readOnly: Boolean = false, - imeAction: ImeAction = ImeAction.Default, - keyboardActions: KeyboardActions = KeyboardActions(), placeholderText: String? = stringResource(R.string.login_password_placeholder), labelText: String? = stringResource(R.string.login_password_label), labelMandatoryIcon: Boolean = false, descriptionText: String? = null, state: WireTextFieldState = WireTextFieldState.Default, + autofill: Boolean, + maxTextLength: Int = 8000, + imeAction: ImeAction = ImeAction.Default, + onImeAction: (() -> Unit)? = null, + scrollState: ScrollState = rememberScrollState(), interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, - textStyle: TextStyle = LocalTextStyle.current.copy(fontSize = 15.sp, textAlign = TextAlign.Start), - placeHolderTextStyle: TextStyle = LocalTextStyle.current.copy(fontSize = 15.sp, textAlign = TextAlign.Start), - inputMinHeight: Dp = 48.dp, - shape: Shape = RoundedCornerShape(16.dp), + textStyle: TextStyle = MaterialTheme.wireTypography.body01.copy(textAlign = TextAlign.Start), + placeholderTextStyle: TextStyle = MaterialTheme.wireTypography.body01.copy(textAlign = TextAlign.Start), + placeholderAlignment: Alignment.Horizontal = Alignment.Start, + inputMinHeight: Dp = MaterialTheme.wireDimensions.textFieldMinHeight, + shape: Shape = RoundedCornerShape(MaterialTheme.wireDimensions.textFieldCornerSize), colors: WireTextFieldColors = wireTextFieldColors(), modifier: Modifier = Modifier, - autofill: Boolean, - onTap: (Offset) -> Unit = { }, + onTap: ((Offset) -> Unit)? = null, testTag: String = String.EMPTY, ) { - var passwordVisibility by remember { mutableStateOf(false) } - - val keyBoardOption = remember { - KeyboardOptions(keyboardType = KeyboardType.Password, autoCorrect = false, imeAction = imeAction) - } - - val visualTransformation = remember(passwordVisibility) { - if (passwordVisibility) VisualTransformation.None else PasswordVisualTransformation() - } - - val icon = remember(passwordVisibility) { - if (passwordVisibility) Icons.Filled.Visibility else Icons.Filled.VisibilityOff - } - - val iconButton = @Composable { - IconButton(onClick = { passwordVisibility = !passwordVisibility }) { - Icon( - imageVector = icon, - contentDescription = stringResource( - if (!passwordVisibility) R.string.content_description_reveal_password - else R.string.content_description_hide_password - ), - modifier = Modifier - .size(20.dp) - .testTag("hidePassword") + val textState = remember { TextFieldState(value.text, value.selection) } + val autoFillType = if (autofill) WireAutoFillType.Password else WireAutoFillType.None + WireTextFieldLayout( + shouldShowPlaceholder = textState.text.isEmpty(), + placeholderText = placeholderText, + labelText = labelText, + labelMandatoryIcon = labelMandatoryIcon, + descriptionText = descriptionText, + state = state, + interactionSource = interactionSource, + placeholderTextStyle = placeholderTextStyle, + placeholderAlignment = placeholderAlignment, + inputMinHeight = inputMinHeight, + shape = shape, + colors = colors, + modifier = modifier.autoFill(autoFillType, textState::setTextAndPlaceCursorAtEnd), + testTag = testTag, + onTap = onTap, + innerBasicTextField = { decorator, textFieldModifier -> + BasicSecureTextField( + state = textState, + textStyle = textStyle.copy(color = colors.textColor(state = state).value, textDirection = TextDirection.ContentOrLtr), + imeAction = imeAction, + onSubmit = { onImeAction?.invoke().let { onImeAction != null } }, + inputTransformation = InputTransformation.maxLength(maxTextLength), + scrollState = scrollState, + enabled = state !is WireTextFieldState.Disabled, + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + interactionSource = interactionSource, + modifier = textFieldModifier.then(StateSyncingModifier(textState, value, onValueChange)), + decorator = decorator, ) } - } - - if (autofill) { - AutoFillTextField( - value = value, - onValueChange = onValueChange, - readOnly = readOnly, - singleLine = true, - maxLines = 1, - keyboardOptions = keyBoardOption, - keyboardActions = keyboardActions, - placeholderText = placeholderText, - labelText = labelText, - labelMandatoryIcon = labelMandatoryIcon, - descriptionText = descriptionText, - state = state, - interactionSource = interactionSource, - textStyle = textStyle, - placeholderTextStyle = placeHolderTextStyle, - inputMinHeight = inputMinHeight, - shape = shape, - colors = colors, - modifier = modifier, - visualTransformation = visualTransformation, - trailingIcon = iconButton, - autofillTypes = listOf(AutofillType.Password), - onTap = onTap, - testTag = testTag - ) - } else { - WireTextField( - value = value, - onValueChange = onValueChange, - readOnly = readOnly, - singleLine = true, - maxLines = 1, - keyboardOptions = keyBoardOption, - keyboardActions = keyboardActions, - placeholderText = placeholderText, - labelText = labelText, - labelMandatoryIcon = labelMandatoryIcon, - descriptionText = descriptionText, - state = state, - interactionSource = interactionSource, - textStyle = textStyle, - placeholderTextStyle = placeHolderTextStyle, - inputMinHeight = inputMinHeight, - shape = shape, - colors = colors, - modifier = modifier, - visualTransformation = visualTransformation, - trailingIcon = iconButton, - onTap = onTap, - testTag = testTag - ) - } + ) } -@Preview(name = "Default WirePasswordTextField") +@OptIn(ExperimentalFoundationApi::class) +@PreviewMultipleThemes @Composable -fun PreviewWirePasswordTextField() { +fun PreviewWirePasswordTextField() = WireTheme { WirePasswordTextField( - value = TextFieldValue(""), - onValueChange = {}, - modifier = Modifier.padding(16.dp), - autofill = false + textState = rememberTextFieldState(), + modifier = Modifier.padding(16.dp) ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextField.kt b/app/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextField.kt index 270ef97bffd..a2e90142e54 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextField.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextField.kt @@ -18,85 +18,69 @@ package com.wire.android.ui.common.textfield -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.interaction.InteractionSource +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.ScrollState import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Box -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.heightIn import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.input.InputTransformation +import androidx.compose.foundation.text.input.OutputTransformation +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.maxLength +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.ErrorOutline import androidx.compose.material.icons.filled.Search import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.vectorResource -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.text.input.VisualTransformation -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDirection -import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp -import com.wire.android.R -import com.wire.android.ui.common.Icon -import com.wire.android.ui.common.Tint +import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireDimensions import com.wire.android.ui.theme.wireTypography import com.wire.android.util.EMPTY +import com.wire.android.util.ui.PreviewMultipleThemes +@OptIn(ExperimentalFoundationApi::class) @Composable internal fun WireTextField( - value: TextFieldValue, - onValueChange: (TextFieldValue) -> Unit, - readOnly: Boolean = false, - singleLine: Boolean = true, - maxLines: Int = 1, - maxTextLength: Int = 8000, - keyboardOptions: KeyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences, autoCorrect = true), - keyboardActions: KeyboardActions = KeyboardActions(), - leadingIcon: @Composable (() -> Unit)? = null, - trailingIcon: @Composable (() -> Unit)? = null, + textState: TextFieldState, placeholderText: String? = null, labelText: String? = null, labelMandatoryIcon: Boolean = false, descriptionText: String? = null, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, state: WireTextFieldState = WireTextFieldState.Default, + autoFillType: WireAutoFillType = WireAutoFillType.None, + lineLimits: TextFieldLineLimits = TextFieldLineLimits.Default, + inputTransformation: InputTransformation = InputTransformation.maxLength(8000), + outputTransformation: OutputTransformation? = null, + keyboardOptions: KeyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences, autoCorrect = true), + keyboardActions: KeyboardActions = KeyboardActions.Default, + scrollState: ScrollState = rememberScrollState(), interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, - visualTransformation: VisualTransformation = VisualTransformation.None, textStyle: TextStyle = MaterialTheme.wireTypography.body01, placeholderTextStyle: TextStyle = MaterialTheme.wireTypography.body01, placeholderAlignment: Alignment.Horizontal = Alignment.Start, @@ -106,273 +90,209 @@ internal fun WireTextField( modifier: Modifier = Modifier, onSelectedLineIndexChanged: (Int) -> Unit = { }, onLineBottomYCoordinateChanged: (Float) -> Unit = { }, - shouldDetectTaps: Boolean = false, + onTap: ((Offset) -> Unit)? = null, testTag: String = String.EMPTY, - onTap: (Offset) -> Unit = { }, -) { - val enabled = state !is WireTextFieldState.Disabled - var updatedText by remember { mutableStateOf(value) } - - Column(modifier = modifier) { - if (labelText != null) { - Label(labelText, labelMandatoryIcon, state, interactionSource, colors) - } - BasicTextField( - value = value, - onValueChange = { - updatedText = if (singleLine || maxLines == 1) { - it.copy(it.text.replace("\n", "")) - } else it - - if (updatedText.text.length > maxTextLength) { - updatedText = TextFieldValue( - text = updatedText.text.take(maxTextLength), - selection = TextRange(updatedText.text.length - 1) - ) - } - - onValueChange(updatedText) - }, - textStyle = textStyle.copy(color = colors.textColor(state = state).value, textDirection = TextDirection.ContentOrLtr), - keyboardOptions = keyboardOptions, - keyboardActions = keyboardActions, - singleLine = singleLine, - maxLines = maxLines, - readOnly = readOnly, - enabled = enabled, - cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), - visualTransformation = visualTransformation, - interactionSource = interactionSource, - modifier = Modifier - .fillMaxWidth() - .background(color = colors.backgroundColor(state).value, shape = shape) - .border(width = 1.dp, color = colors.borderColor(state, interactionSource).value, shape = shape) - .semantics { - (labelText ?: placeholderText ?: descriptionText)?.let { - contentDescription = it - } - } - .testTag(testTag), - decorationBox = { innerTextField -> - InnerText( - innerTextField, - value, - leadingIcon, - trailingIcon, - placeholderText, - state, - placeholderTextStyle, - placeholderAlignment, - inputMinHeight, - colors, - shouldDetectTaps, - onTap - ) - }, - onTextLayout = { - val lineOfText = it.getLineForOffset(value.selection.end) - val bottomYCoordinate = it.getLineBottom(lineOfText) - onSelectedLineIndexChanged(lineOfText) - onLineBottomYCoordinateChanged(bottomYCoordinate) - } - ) - val bottomText = when { - state is WireTextFieldState.Error && state.errorText != null -> state.errorText - !descriptionText.isNullOrEmpty() -> descriptionText - else -> String.EMPTY - } - AnimatedVisibility(visible = bottomText.isNotEmpty()) { - Text( - text = bottomText, - style = MaterialTheme.wireTypography.label04, - textAlign = TextAlign.Start, - color = colors.descriptionColor(state).value, - modifier = Modifier.padding(top = 4.dp) - ) - } - } -} - -@Composable -fun Label( - labelText: String, - labelMandatoryIcon: Boolean, - state: WireTextFieldState, - interactionSource: InteractionSource, - colors: WireTextFieldColors ) { - Row { - Text( - text = labelText, - style = MaterialTheme.wireTypography.label01, - color = colors.labelColor(state, interactionSource).value, - modifier = Modifier.padding(bottom = 4.dp, end = 4.dp) - ) - if (labelMandatoryIcon) { - Icon( - imageVector = ImageVector.vectorResource(id = R.drawable.ic_input_mandatory), - tint = colors.labelMandatoryColor(state).value, - contentDescription = "", - modifier = Modifier.padding(top = 2.dp) + WireTextFieldLayout( + shouldShowPlaceholder = textState.text.isEmpty(), + placeholderText = placeholderText, + labelText = labelText, + labelMandatoryIcon = labelMandatoryIcon, + descriptionText = descriptionText, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + state = state, + interactionSource = interactionSource, + placeholderTextStyle = placeholderTextStyle, + placeholderAlignment = placeholderAlignment, + inputMinHeight = inputMinHeight, + shape = shape, + colors = colors, + modifier = modifier.autoFill(autoFillType, textState::setTextAndPlaceCursorAtEnd), + onTap = onTap, + testTag = testTag, + innerBasicTextField = { decorator, textFieldModifier -> + BasicTextField( + state = textState, + textStyle = textStyle.copy(color = colors.textColor(state = state).value, textDirection = TextDirection.ContentOrLtr), + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + lineLimits = lineLimits, + inputTransformation = inputTransformation, + outputTransformation = outputTransformation, + scrollState = scrollState, + readOnly = state is WireTextFieldState.ReadOnly, + enabled = state !is WireTextFieldState.Disabled, + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + interactionSource = interactionSource, + modifier = textFieldModifier, + decorator = decorator, + onTextLayout = onTextLayout(textState, onSelectedLineIndexChanged, onLineBottomYCoordinateChanged) ) } - } + ) } +/* +TODO: BasicTextField2 (value, onValueChange) overload is removed completely in compose foundation 1.7.0, + for now we can use our custom StateSyncingModifier to sync TextFieldValue with TextFieldState, + but eventually we should migrate and remove this function when all usages are replaced with the TextFieldState. +*/ +@OptIn(ExperimentalFoundationApi::class) +@Deprecated("Use the new one with TextFieldState.") @Composable -private fun InnerText( - innerTextField: @Composable () -> Unit, +internal fun WireTextField( value: TextFieldValue, + onValueChange: (TextFieldValue) -> Unit, + placeholderText: String? = null, + labelText: String? = null, + labelMandatoryIcon: Boolean = false, + descriptionText: String? = null, leadingIcon: @Composable (() -> Unit)? = null, trailingIcon: @Composable (() -> Unit)? = null, - placeholderText: String? = null, + readOnly: Boolean = false, state: WireTextFieldState = WireTextFieldState.Default, + autoFillType: WireAutoFillType = WireAutoFillType.None, + maxLines: Int = 1, + singleLine: Boolean = true, + maxTextLength: Int = 8000, + keyboardOptions: KeyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences, autoCorrect = true), + keyboardActions: KeyboardActions = KeyboardActions.Default, + scrollState: ScrollState = rememberScrollState(), + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + textStyle: TextStyle = MaterialTheme.wireTypography.body01, placeholderTextStyle: TextStyle = MaterialTheme.wireTypography.body01, placeholderAlignment: Alignment.Horizontal = Alignment.Start, - inputMinHeight: Dp = 48.dp, + inputMinHeight: Dp = MaterialTheme.wireDimensions.textFieldMinHeight, + shape: Shape = RoundedCornerShape(MaterialTheme.wireDimensions.textFieldCornerSize), colors: WireTextFieldColors = wireTextFieldColors(), - shouldDetectTaps: Boolean = false, - onClick: (Offset) -> Unit = { } + modifier: Modifier = Modifier, + onSelectedLineIndexChanged: (Int) -> Unit = { }, + onLineBottomYCoordinateChanged: (Float) -> Unit = { }, + onTap: ((Offset) -> Unit)? = null, + testTag: String = String.EMPTY, ) { - var modifier: Modifier = Modifier - if (shouldDetectTaps) { - modifier = modifier.pointerInput(Unit) { - detectTapGestures(onTap = onClick) - } - } - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = modifier - .heightIn(min = inputMinHeight) - ) { - - val trailingOrStateIcon: @Composable (() -> Unit)? = when { - trailingIcon != null -> trailingIcon - else -> state.icon()?.Icon(Modifier.padding(horizontal = 16.dp)) - } - if (leadingIcon != null) { - Box(contentAlignment = Alignment.Center) { - Tint(contentColor = colors.iconColor(state).value, content = leadingIcon) - } - } - - Box( - Modifier - .weight(1f) - .padding( - start = if (leadingIcon == null) 16.dp else 0.dp, - end = if (trailingOrStateIcon == null) 16.dp else 0.dp, - top = 2.dp, bottom = 2.dp - ) - ) { - if (value.text.isEmpty() && placeholderText != null) { - Text( - text = placeholderText, - style = placeholderTextStyle, - color = colors.placeholderColor(state).value, - modifier = Modifier - .align(placeholderAlignment.toAlignment()) - ) - } - Box( - modifier = Modifier.fillMaxWidth(), - propagateMinConstraints = true - ) { - innerTextField() - } - } - if (trailingOrStateIcon != null) { - Box(contentAlignment = Alignment.Center) { - Tint(contentColor = colors.iconColor(state).value, content = trailingOrStateIcon) - } + val textState = remember { TextFieldState(value.text, value.selection) } + val lineLimits = if (singleLine) TextFieldLineLimits.SingleLine else TextFieldLineLimits.MultiLine(1, maxLines) + WireTextFieldLayout( + shouldShowPlaceholder = value.text.isEmpty(), + placeholderText = placeholderText, + labelText = labelText, + labelMandatoryIcon = labelMandatoryIcon, + descriptionText = descriptionText, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + state = state, + interactionSource = interactionSource, + placeholderTextStyle = placeholderTextStyle, + placeholderAlignment = placeholderAlignment, + inputMinHeight = inputMinHeight, + shape = shape, + colors = colors, + modifier = modifier.autoFill(autoFillType, textState::setTextAndPlaceCursorAtEnd), + onTap = onTap, + testTag = testTag, + innerBasicTextField = { decorator, textFieldModifier -> + BasicTextField( + state = textState, + textStyle = textStyle.copy(color = colors.textColor(state = state).value, textDirection = TextDirection.ContentOrLtr), + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + lineLimits = lineLimits, + inputTransformation = InputTransformation.maxLength(maxTextLength), + scrollState = scrollState, + readOnly = readOnly, + enabled = state !is WireTextFieldState.Disabled, + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + interactionSource = interactionSource, + modifier = textFieldModifier.then(StateSyncingModifier(textState, value, onValueChange)), + decorator = decorator, + onTextLayout = onTextLayout(textState, onSelectedLineIndexChanged, onLineBottomYCoordinateChanged) + ) } - } + ) } -private fun Alignment.Horizontal.toAlignment(): Alignment = Alignment { size, space, layoutDirection -> - IntOffset(this@toAlignment.align(size.width, space.width, layoutDirection), 0) +@OptIn(ExperimentalFoundationApi::class) +private fun onTextLayout( + state: TextFieldState, + onSelectedLineIndexChanged: (Int) -> Unit = { }, + onLineBottomYCoordinateChanged: (Float) -> Unit = { }, +): (Density.(getResult: () -> TextLayoutResult?) -> Unit) = { + it()?.let { + val lineOfText = it.getLineForOffset(state.text.selection.end) + val bottomYCoordinate = it.getLineBottom(lineOfText) + onSelectedLineIndexChanged(lineOfText) + onLineBottomYCoordinateChanged(bottomYCoordinate) + } } -@Preview(name = "Default WireTextField") +@OptIn(ExperimentalFoundationApi::class) +@PreviewMultipleThemes @Composable -fun PreviewWireTextField() { +fun PreviewWireTextField() = WireTheme { WireTextField( - value = TextFieldValue("text"), - onValueChange = {}, + textState = rememberTextFieldState("text"), modifier = Modifier.padding(16.dp) ) } -@Preview(name = "Default WireTextField with labels") +@OptIn(ExperimentalFoundationApi::class) +@PreviewMultipleThemes @Composable -fun PreviewWireTextFieldLabels() { +fun PreviewWireTextFieldLabels() = WireTheme { WireTextField( - value = TextFieldValue("text"), + textState = rememberTextFieldState("text"), labelText = "label", labelMandatoryIcon = true, descriptionText = "description", - onValueChange = {}, modifier = Modifier.padding(16.dp) ) } -@Preview(name = "Dense Search WireTextField") +@OptIn(ExperimentalFoundationApi::class) +@PreviewMultipleThemes @Composable -fun PreviewWireTextFieldDenseSearch() { +fun PreviewWireTextFieldDenseSearch() = WireTheme { WireTextField( - value = TextFieldValue(""), + textState = rememberTextFieldState("text"), placeholderText = "Search", leadingIcon = { IconButton(modifier = Modifier.height(40.dp), onClick = {}) { Icon(Icons.Filled.Search, "") } }, trailingIcon = { IconButton(modifier = Modifier.height(40.dp), onClick = {}) { Icon(Icons.Filled.Close, "") } }, - onValueChange = {}, inputMinHeight = 40.dp, modifier = Modifier.padding(16.dp) ) } -@Preview(name = "Disabled WireTextField") +@OptIn(ExperimentalFoundationApi::class) +@PreviewMultipleThemes @Composable -fun PreviewWireTextFieldDisabled() { +fun PreviewWireTextFieldDisabled() = WireTheme { WireTextField( - value = TextFieldValue("text"), - onValueChange = {}, + textState = rememberTextFieldState("text"), state = WireTextFieldState.Disabled, modifier = Modifier.padding(16.dp) ) } -@Preview(name = "Error WireTextField") +@OptIn(ExperimentalFoundationApi::class) +@PreviewMultipleThemes @Composable -fun PreviewWireTextFieldError() { +fun PreviewWireTextFieldError() = WireTheme { WireTextField( - value = TextFieldValue("text"), - onValueChange = {}, + textState = rememberTextFieldState("text"), state = WireTextFieldState.Error("error"), modifier = Modifier.padding(16.dp) ) } -@Preview(name = "Success WireTextField") +@OptIn(ExperimentalFoundationApi::class) +@PreviewMultipleThemes @Composable -fun PreviewWireTextFieldSuccess() { +fun PreviewWireTextFieldSuccess() = WireTheme { WireTextField( - value = TextFieldValue("text"), - onValueChange = {}, + textState = rememberTextFieldState("text"), state = WireTextFieldState.Success, modifier = Modifier.padding(16.dp) ) } - -sealed class WireTextFieldState { - object Default : WireTextFieldState() - data class Error(val errorText: String? = null) : WireTextFieldState() - object Success : WireTextFieldState() - object Disabled : WireTextFieldState() - - fun icon(): ImageVector? = when (this) { - is Error -> Icons.Filled.ErrorOutline - is Success -> Icons.Filled.Check - else -> null - } -} diff --git a/app/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextFieldAutoFill.kt b/app/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextFieldAutoFill.kt new file mode 100644 index 00000000000..9c90370d2e4 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextFieldAutoFill.kt @@ -0,0 +1,77 @@ +/* + * 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/. + */ +@file:OptIn(ExperimentalComposeUiApi::class) + +package com.wire.android.ui.common.textfield + +import androidx.compose.runtime.Composable +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.autofill.Autofill +import androidx.compose.ui.autofill.AutofillNode +import androidx.compose.ui.autofill.AutofillType +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalAutofill +import androidx.compose.ui.platform.LocalAutofillTree +import io.github.esentsov.PackagePrivate + +@OptIn(ExperimentalComposeUiApi::class) +@PackagePrivate +@Composable +internal fun Modifier.autoFill(type: WireAutoFillType, onFill: ((String) -> Unit)) = this.let { modifier -> + if (type.autoFillTypes.isNotEmpty()) { + val autofillNode = AutofillNode( + autofillTypes = type.autoFillTypes, + onFill = onFill, + ) + LocalAutofillTree.current += autofillNode + modifier + .fillBounds(autofillNode) + .defaultOnFocusAutoFill(LocalAutofill.current, autofillNode) + } else { + modifier + } +} + +@OptIn(ExperimentalComposeUiApi::class) +private fun Modifier.fillBounds(autofillNode: AutofillNode) = this.then( + Modifier.onGloballyPositioned { autofillNode.boundingBox = it.boundsInWindow() } +) + +private fun Modifier.defaultOnFocusAutoFill(autofill: Autofill?, autofillNode: AutofillNode): Modifier = + then( + Modifier.onFocusChanged { + focusState -> + if (focusState.isFocused) { + autofill?.requestAutofillForNode(autofillNode) + } else { + autofill?.cancelAutofillForNode(autofillNode) + } + } + ) + +@Composable +fun clearAutofillTree() = LocalAutofillTree.current.children.clear() + +enum class WireAutoFillType(val autoFillTypes: List) { + None(emptyList()), + Login(listOf(AutofillType.EmailAddress, AutofillType.Username)), + Password(listOf(AutofillType.Password)), +} diff --git a/app/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextFieldLayout.kt b/app/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextFieldLayout.kt new file mode 100644 index 00000000000..0e88b4b82c6 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextFieldLayout.kt @@ -0,0 +1,206 @@ +/* + * 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.common.textfield + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.input.TextFieldDecorator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import com.wire.android.ui.common.Icon +import com.wire.android.ui.common.Tint +import com.wire.android.ui.common.dimensions +import com.wire.android.ui.theme.wireDimensions +import com.wire.android.ui.theme.wireTypography +import com.wire.android.util.EMPTY +import io.github.esentsov.PackagePrivate + +@PackagePrivate +@Composable +internal fun WireTextFieldLayout( + shouldShowPlaceholder: Boolean, + placeholderText: String? = null, + labelText: String? = null, + labelMandatoryIcon: Boolean = false, + descriptionText: String? = null, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, + state: WireTextFieldState = WireTextFieldState.Default, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + placeholderTextStyle: TextStyle = MaterialTheme.wireTypography.body01, + placeholderAlignment: Alignment.Horizontal = Alignment.Start, + inputMinHeight: Dp = MaterialTheme.wireDimensions.textFieldMinHeight, + shape: Shape = RoundedCornerShape(MaterialTheme.wireDimensions.textFieldCornerSize), + colors: WireTextFieldColors = wireTextFieldColors(), + modifier: Modifier = Modifier, + onTap: ((Offset) -> Unit)? = null, + testTag: String = String.EMPTY, + innerBasicTextField: InnerBasicTextFieldBuilder, +) { + Column(modifier = modifier) { + if (labelText != null) { + Label(labelText, labelMandatoryIcon, state, interactionSource, colors) + } + innerBasicTextField( + decorator = TextFieldDecorator { innerTextField -> + InnerTextLayout( + innerTextField = innerTextField, + shouldShowPlaceholder = shouldShowPlaceholder, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + placeholderText = placeholderText, + style = state, + placeholderTextStyle = placeholderTextStyle, + placeholderAlignment = placeholderAlignment, + inputMinHeight = inputMinHeight, + colors = colors, + onTap = onTap, + ) + }, + textFieldModifier = Modifier + .fillMaxWidth() + .background(color = colors.backgroundColor(state).value, shape = shape) + .border(width = dimensions().spacing1x, color = colors.borderColor(state, interactionSource).value, shape = shape) + .semantics { + (labelText ?: placeholderText ?: descriptionText)?.let { + contentDescription = it + } + } + .testTag(testTag) + ) + + val bottomText = when { + state is WireTextFieldState.Error && state.errorText != null -> state.errorText + !descriptionText.isNullOrEmpty() -> descriptionText + else -> String.EMPTY + } + AnimatedVisibility(visible = bottomText.isNotEmpty()) { + Text( + text = bottomText, + style = MaterialTheme.wireTypography.label04, + textAlign = TextAlign.Start, + color = colors.descriptionColor(state).value, + modifier = Modifier.padding(top = dimensions().spacing4x) + ) + } + } +} + +@Composable +private fun InnerTextLayout( + innerTextField: @Composable () -> Unit, + shouldShowPlaceholder: Boolean, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, + placeholderText: String? = null, + style: WireTextFieldState = WireTextFieldState.Default, + placeholderTextStyle: TextStyle = MaterialTheme.wireTypography.body01, + placeholderAlignment: Alignment.Horizontal = Alignment.Start, + inputMinHeight: Dp = dimensions().spacing48x, + colors: WireTextFieldColors = wireTextFieldColors(), + onTap: ((Offset) -> Unit)? = null +) { + val modifier: Modifier = Modifier.apply { + if (onTap != null) { + pointerInput(Unit) { + detectTapGestures(onTap = onTap) + } + } + } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .heightIn(min = inputMinHeight) + ) { + val trailingOrStateIcon: @Composable (() -> Unit)? = when { + trailingIcon != null -> trailingIcon + else -> style.icon()?.Icon(Modifier.padding(horizontal = dimensions().spacing16x)) + } + if (leadingIcon != null) { + Box(contentAlignment = Alignment.Center) { + Tint(contentColor = colors.iconColor(style).value, content = leadingIcon) + } + } + + Box( + modifier = Modifier + .weight(1f) + .padding( + start = if (leadingIcon == null) dimensions().spacing16x else dimensions().spacing0x, + end = if (trailingOrStateIcon == null) dimensions().spacing16x else dimensions().spacing0x, + top = dimensions().spacing2x, + bottom = dimensions().spacing2x, + ) + ) { + if (shouldShowPlaceholder && placeholderText != null) { + Text( + text = placeholderText, + style = placeholderTextStyle, + color = colors.placeholderColor(style).value, + modifier = Modifier.align(placeholderAlignment.toAlignment()) + ) + } + Box( + modifier = Modifier.fillMaxWidth(), + propagateMinConstraints = true + ) { + innerTextField() + } + } + if (trailingOrStateIcon != null) { + Box(contentAlignment = Alignment.Center) { + Tint(contentColor = colors.iconColor(style).value, content = trailingOrStateIcon) + } + } + } +} + +private fun Alignment.Horizontal.toAlignment(): Alignment = Alignment { size, space, layoutDirection -> + IntOffset(this@toAlignment.align(size.width, space.width, layoutDirection), 0) +} + +fun interface InnerBasicTextFieldBuilder { + @Composable + operator fun invoke(decorator: TextFieldDecorator, textFieldModifier: Modifier) +} diff --git a/app/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextFieldState.kt b/app/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextFieldState.kt new file mode 100644 index 00000000000..957e1972281 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextFieldState.kt @@ -0,0 +1,37 @@ +/* + * 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.common.textfield + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.ErrorOutline +import androidx.compose.ui.graphics.vector.ImageVector + +sealed class WireTextFieldState { + data object Default : WireTextFieldState() + data class Error(val errorText: String? = null) : WireTextFieldState() + data object Success : WireTextFieldState() + data object Disabled : WireTextFieldState() + data object ReadOnly : WireTextFieldState() + + fun icon(): ImageVector? = when (this) { + is Error -> Icons.Filled.ErrorOutline + is Success -> Icons.Filled.Check + else -> null + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/appLock/forgot/ForgotLockCodeResetDeviceDialog.kt b/app/src/main/kotlin/com/wire/android/ui/home/appLock/forgot/ForgotLockCodeResetDeviceDialog.kt index d975c4038a0..f654c8ede74 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/appLock/forgot/ForgotLockCodeResetDeviceDialog.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/appLock/forgot/ForgotLockCodeResetDeviceDialog.kt @@ -18,14 +18,12 @@ package com.wire.android.ui.home.appLock.forgot import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.text.KeyboardActions import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalSoftwareKeyboardController @@ -48,7 +46,6 @@ import com.wire.android.ui.theme.wireTypography import com.wire.android.util.ui.PreviewMultipleThemes import com.wire.android.util.ui.stringWithStyledArgs -@OptIn(ExperimentalComposeUiApi::class) @Composable fun ForgotLockCodeResetDeviceDialog( username: String, @@ -111,7 +108,7 @@ fun ForgotLockCodeResetDeviceDialog( onPasswordChanged(it) }, autofill = false, - keyboardActions = KeyboardActions(onDone = { keyboardController?.hide() }), + onImeAction = { keyboardController?.hide() }, modifier = Modifier.padding(bottom = dimensions().spacing16x) ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/RegularMessageItem.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/RegularMessageItem.kt index 88050562468..f2c731f0e43 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/RegularMessageItem.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/RegularMessageItem.kt @@ -20,6 +20,7 @@ package com.wire.android.ui.home.conversations.messages.item import androidx.compose.animation.core.FastOutLinearInEasing import androidx.compose.animation.core.tween +import androidx.compose.animation.splineBasedDecay import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.gestures.AnchoredDraggableState import androidx.compose.foundation.gestures.DraggableAnchors @@ -307,7 +308,8 @@ private fun SwipableToReplyBox( initialValue = SwipeAnchor.CENTERED, positionalThreshold = { dragWidth }, velocityThreshold = { screenWidth }, - animationSpec = tween(), + snapAnimationSpec = tween(), + decayAnimationSpec = splineBasedDecay(density), confirmValueChange = { changedValue -> if (changedValue == SwipeAnchor.START_TO_END) { // Attempt to finish dismiss, notify reply intention diff --git a/app/src/main/kotlin/com/wire/android/ui/joinConversation/JoinConversationViaDeepLinkDialog.kt b/app/src/main/kotlin/com/wire/android/ui/joinConversation/JoinConversationViaDeepLinkDialog.kt index 24bd86fb746..163e55dfabb 100644 --- a/app/src/main/kotlin/com/wire/android/ui/joinConversation/JoinConversationViaDeepLinkDialog.kt +++ b/app/src/main/kotlin/com/wire/android/ui/joinConversation/JoinConversationViaDeepLinkDialog.kt @@ -20,14 +20,12 @@ package com.wire.android.ui.joinConversation import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.text.KeyboardActions 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.remember -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester @@ -68,7 +66,6 @@ sealed interface JoinConversationViaCodeState { data class Error(val error: CheckConversationInviteCodeUseCase.Result.Failure) : JoinConversationViaCodeState } -@OptIn(ExperimentalComposeUiApi::class) @Composable fun JoinConversationViaDeepLinkDialog( name: String?, @@ -160,7 +157,7 @@ fun JoinConversationViaDeepLinkDialog( else -> WireTextFieldState.Default }, imeAction = ImeAction.Done, - keyboardActions = KeyboardActions(onDone = { keyboardController?.hide() }), + onImeAction = { keyboardController?.hide() }, modifier = Modifier .focusRequester(focusRequester) .padding(bottom = MaterialTheme.wireDimensions.spacing8x) diff --git a/app/src/main/kotlin/com/wire/android/ui/legalhold/dialog/requested/LegalHoldRequestedDialog.kt b/app/src/main/kotlin/com/wire/android/ui/legalhold/dialog/requested/LegalHoldRequestedDialog.kt index 938053282cf..794ba992558 100644 --- a/app/src/main/kotlin/com/wire/android/ui/legalhold/dialog/requested/LegalHoldRequestedDialog.kt +++ b/app/src/main/kotlin/com/wire/android/ui/legalhold/dialog/requested/LegalHoldRequestedDialog.kt @@ -21,12 +21,10 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.text.KeyboardActions import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester @@ -53,7 +51,6 @@ import com.wire.android.util.extension.formatAsFingerPrint import com.wire.android.util.ui.PreviewMultipleThemes import com.wire.kalium.logic.data.user.UserId -@OptIn(ExperimentalComposeUiApi::class) @Composable fun LegalHoldRequestedDialog( state: LegalHoldRequestedState.Visible, @@ -124,7 +121,7 @@ fun LegalHoldRequestedDialog( else -> WireTextFieldState.Default }, imeAction = ImeAction.Done, - keyboardActions = KeyboardActions(onDone = { keyboardController?.hide() }), + onImeAction = { keyboardController?.hide() }, modifier = Modifier .focusRequester(focusRequester) .padding(bottom = MaterialTheme.wireDimensions.spacing8x) diff --git a/core/ui-common/build.gradle.kts b/core/ui-common/build.gradle.kts index 5262f93c7f0..b01cc651ee2 100644 --- a/core/ui-common/build.gradle.kts +++ b/core/ui-common/build.gradle.kts @@ -17,6 +17,7 @@ dependencies { implementation(composeBom) implementation(libs.compose.ui) implementation(libs.compose.foundation) + implementation(libs.compose.material.android) implementation(libs.compose.ui.graphics) implementation(libs.compose.material.core) implementation(libs.compose.material3) diff --git a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/snackbar/SwipeableSnackbar.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/snackbar/SwipeableSnackbar.kt index 91e126d15b2..f9e5bbf8659 100644 --- a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/snackbar/SwipeableSnackbar.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/snackbar/SwipeableSnackbar.kt @@ -18,6 +18,7 @@ package com.wire.android.ui.common.snackbar import androidx.compose.animation.core.SpringSpec +import androidx.compose.animation.splineBasedDecay import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.gestures.AnchoredDraggableState import androidx.compose.foundation.gestures.DraggableAnchors @@ -89,7 +90,8 @@ fun SwipeableSnackbar( anchors = anchors, positionalThreshold = positionalThreshold, velocityThreshold = velocityThreshold, - animationSpec = SpringSpec(), + snapAnimationSpec = SpringSpec(), + decayAnimationSpec = splineBasedDecay(density), confirmValueChange = { true } ) } diff --git a/features/sketch/build.gradle.kts b/features/sketch/build.gradle.kts index 5f7e54c713a..c06d5df7849 100644 --- a/features/sketch/build.gradle.kts +++ b/features/sketch/build.gradle.kts @@ -14,6 +14,7 @@ dependencies { implementation(composeBom) implementation(libs.compose.ui) implementation(libs.compose.foundation) + implementation(libs.compose.material.android) implementation(libs.compose.ui.graphics) implementation(libs.compose.material.core) implementation(libs.compose.material3) diff --git a/features/template/build.gradle.kts b/features/template/build.gradle.kts index 8f553b3fa3b..89bb9604ba3 100644 --- a/features/template/build.gradle.kts +++ b/features/template/build.gradle.kts @@ -12,6 +12,7 @@ dependencies { implementation(composeBom) implementation(libs.compose.ui) implementation(libs.compose.foundation) + implementation(libs.compose.material.android) testImplementation(libs.junit4) androidTestImplementation(libs.androidx.test.extJunit) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7a4eb4a4699..9f1e52e832c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -45,6 +45,8 @@ androidx-startup = "1.1.1" # Compose composeBom = "2024.04.01" +compose-foundation = "1.7.0-alpha05" # remove when composeBom contains new stable version of BasicTextField2 +compose-material-android = "1.7.0-alpha05" # remove when composeBom contains new stable version of BasicTextField2 compose-activity = "1.8.2" compose-compiler = "1.5.11" compose-constraint = "1.0.1" @@ -181,7 +183,8 @@ hilt-work = { module = "androidx.hilt:hilt-work", version.ref = "hilt-work" } # Compose BOM compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } -compose-foundation = { module = "androidx.compose.foundation:foundation" } +compose-foundation = { module = "androidx.compose.foundation:foundation", version.ref = "compose-foundation" } +compose-material-android = { module = "androidx.compose.material:material-android", version.ref = "compose-material-android" } 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" }