diff --git a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/joinbyaddress/JoinBaseRoomByAddressViewTest.kt b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/joinbyaddress/JoinBaseRoomByAddressViewTest.kt index 93db738d35c..b2e75c9f5a2 100644 --- a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/joinbyaddress/JoinBaseRoomByAddressViewTest.kt +++ b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/joinbyaddress/JoinBaseRoomByAddressViewTest.kt @@ -17,6 +17,7 @@ import io.element.android.features.createroom.impl.R import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.tests.testutils.EventsRecorder import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.setSafeContent import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule @@ -56,7 +57,7 @@ class JoinBaseRoomByAddressViewTest { private fun AndroidComposeTestRule.setJoinRoomByAddressView( state: JoinRoomByAddressState, ) { - setContent { + setSafeContent { JoinRoomByAddressView(state = state) } } diff --git a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationView.kt b/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationView.kt index b7d9ab4ba1a..9e61f030c52 100644 --- a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationView.kt +++ b/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationView.kt @@ -34,10 +34,12 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier -import androidx.compose.ui.autofill.AutofillType +import androidx.compose.ui.autofill.ContentType import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentType +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation @@ -54,7 +56,6 @@ import io.element.android.libraries.designsystem.atomic.organisms.InfoListOrgani import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.form.textFieldState import io.element.android.libraries.designsystem.components.list.SwitchListItem -import io.element.android.libraries.designsystem.modifiers.autofill import io.element.android.libraries.designsystem.modifiers.onTabOrEnterKeyFocusNext import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight @@ -276,14 +277,9 @@ private fun Content( .fillMaxWidth() .onTabOrEnterKeyFocusNext(focusManager) .testTag(TestTags.loginPassword) - .autofill( - autofillTypes = listOf(AutofillType.Password), - onFill = { - val sanitized = it.sanitize() - passwordFieldState = sanitized - eventSink(AccountDeactivationEvents.SetPassword(sanitized)) - } - ), + .semantics { + contentType = ContentType.Password + }, onValueChange = { val sanitized = it.sanitize() passwordFieldState = sanitized diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationView.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationView.kt index 8f5ef7897c6..53d1855e2af 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationView.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationView.kt @@ -20,6 +20,8 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ListItem import androidx.compose.material3.SheetValue +import androidx.compose.material3.rememberBottomSheetScaffoldState +import androidx.compose.material3.rememberStandardBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment @@ -46,8 +48,6 @@ import io.element.android.libraries.designsystem.theme.components.FloatingAction import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.TopAppBar -import io.element.android.libraries.designsystem.theme.components.bottomsheet.rememberBottomSheetScaffoldState -import io.element.android.libraries.designsystem.theme.components.bottomsheet.rememberStandardBottomSheetState import io.element.android.libraries.designsystem.utils.CommonDrawables import io.element.android.libraries.maplibre.compose.CameraMode import io.element.android.libraries.maplibre.compose.CameraMoveStartedReason diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordView.kt index 4bca66dc966..c72f1a03083 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordView.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordView.kt @@ -7,6 +7,7 @@ package io.element.android.features.login.impl.screens.loginpassword +import androidx.activity.compose.BackHandler import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -30,10 +31,13 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier -import androidx.compose.ui.autofill.AutofillType +import androidx.compose.ui.autofill.ContentType import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.platform.LocalAutofillManager import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentType +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation @@ -51,7 +55,6 @@ import io.element.android.libraries.designsystem.components.BigIcon import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog import io.element.android.libraries.designsystem.components.form.textFieldState -import io.element.android.libraries.designsystem.modifiers.autofill import io.element.android.libraries.designsystem.modifiers.onTabOrEnterKeyFocusNext import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight @@ -71,6 +74,13 @@ fun LoginPasswordView( onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { + val autofillManager = LocalAutofillManager.current + + BackHandler { + autofillManager?.cancel() + onBackClick() + } + val isLoading by remember(state.loginAction) { derivedStateOf { state.loginAction is AsyncData.Loading @@ -82,6 +92,8 @@ fun LoginPasswordView( // Clear focus to prevent keyboard issues with textfields focusManager.clearFocus(force = true) + autofillManager?.commit() + state.eventSink(LoginPasswordEvents.Submit) } @@ -90,7 +102,12 @@ fun LoginPasswordView( topBar = { TopAppBar( title = {}, - navigationIcon = { BackButton(onClick = onBackClick) }, + navigationIcon = { + BackButton(onClick = { + autofillManager?.cancel() + onBackClick() + }) + }, ) } ) { padding -> @@ -175,14 +192,9 @@ private fun LoginForm( .fillMaxWidth() .onTabOrEnterKeyFocusNext(focusManager) .testTag(TestTags.loginEmailUsername) - .autofill( - autofillTypes = listOf(AutofillType.Username), - onFill = { - val sanitized = it.sanitize() - loginFieldState = sanitized - eventSink(LoginPasswordEvents.SetLogin(sanitized)) - } - ), + .semantics { + contentType = ContentType.Username + }, placeholder = stringResource(CommonStrings.common_username), onValueChange = { val sanitized = it.sanitize() @@ -227,14 +239,9 @@ private fun LoginForm( .fillMaxWidth() .onTabOrEnterKeyFocusNext(focusManager) .testTag(TestTags.loginPassword) - .autofill( - autofillTypes = listOf(AutofillType.Password), - onFill = { - val sanitized = it.sanitize() - passwordFieldState = sanitized - eventSink(LoginPasswordEvents.SetPassword(sanitized)) - } - ), + .semantics { + contentType = ContentType.Password + }, onValueChange = { val sanitized = it.sanitize() passwordFieldState = sanitized diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordViewTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordViewTest.kt index 45d7bc53e59..99deb8b7bb2 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordViewTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordViewTest.kt @@ -8,17 +8,21 @@ package io.element.android.features.login.impl.screens.loginpassword import androidx.activity.ComponentActivity +import androidx.compose.ui.test.assert import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextInput import androidx.test.ext.junit.runners.AndroidJUnit4 import io.element.android.libraries.matrix.test.A_PASSWORD import io.element.android.libraries.matrix.test.A_USER_NAME +import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.tests.testutils.EnsureNeverCalled import io.element.android.tests.testutils.EventsRecorder @@ -120,15 +124,15 @@ class LoginPasswordViewTest { eventSink = eventsRecorder, ), ) - rule.onNodeWithText(A_PASSWORD).assertDoesNotExist() + rule.onNodeWithTag(TestTags.loginPassword.value).assert(hasText("••••••••")) // Show password val a11yShowPassword = rule.activity.getString(CommonStrings.a11y_show_password) rule.onNodeWithContentDescription(a11yShowPassword).performClick() - rule.onNodeWithText(A_PASSWORD).assertExists() + rule.onNodeWithTag(TestTags.loginPassword.value).assert(hasText(A_PASSWORD)) // Hide password val a11yHidePassword = rule.activity.getString(CommonStrings.a11y_hide_password) rule.onNodeWithContentDescription(a11yHidePassword).performClick() - rule.onNodeWithText(A_PASSWORD).assertDoesNotExist() + rule.onNodeWithTag(TestTags.loginPassword.value).assert(hasText("••••••••")) } @Test diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/ExpandableBottomSheetScaffold.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/ExpandableBottomSheetScaffold.kt index 21b8d033813..ca70ca55de6 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/ExpandableBottomSheetScaffold.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/ExpandableBottomSheetScaffold.kt @@ -13,6 +13,8 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.SheetValue +import androidx.compose.material3.rememberBottomSheetScaffoldState +import androidx.compose.material3.rememberStandardBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf @@ -29,10 +31,8 @@ import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.min +import io.element.android.libraries.core.data.tryOrNull import io.element.android.libraries.designsystem.theme.components.BottomSheetScaffold -import io.element.android.libraries.designsystem.theme.components.bottomsheet.CustomSheetState -import io.element.android.libraries.designsystem.theme.components.bottomsheet.rememberBottomSheetScaffoldState -import io.element.android.libraries.designsystem.theme.components.bottomsheet.rememberStandardBottomSheetState import kotlin.math.roundToInt /** @@ -139,8 +139,8 @@ internal fun ExpandableBottomSheetScaffold( modifier = Modifier.fillMaxHeight(), measurePolicy = { measurables, constraints -> val constraintHeight = constraints.maxHeight - val offset = scaffoldState.bottomSheetState.getIntOffset() ?: 0 - val height = Integer.max(0, constraintHeight - offset) + val offset = tryOrNull { scaffoldState.bottomSheetState.requireOffset() } ?: 0f + val height = Integer.max(0, constraintHeight - offset.roundToInt()) val top = measurables[0].measure( constraints.copy( minHeight = height, @@ -165,12 +165,6 @@ internal fun ExpandableBottomSheetScaffold( ) } -private fun CustomSheetState.getIntOffset(): Int? = try { - requireOffset().roundToInt() -} catch (e: IllegalStateException) { - null -} - private sealed interface Slot { data class SheetContent(val key: Int?) : Slot data object DragHandle : Slot diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageEventBubble.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageEventBubble.kt index ad5337031c3..2b48a99dcd1 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageEventBubble.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/MessageEventBubble.kt @@ -47,6 +47,7 @@ import io.element.android.libraries.designsystem.theme.components.Surface import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.messageFromMeBackground import io.element.android.libraries.designsystem.theme.messageFromOtherBackground +import io.element.android.libraries.designsystem.utils.LocalUiTestMode import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.testtags.testTag import io.element.android.libraries.ui.utils.time.isTalkbackActive @@ -112,7 +113,9 @@ fun MessageEventBubble( state.isMine -> ElementTheme.colors.messageFromMeBackground else -> ElementTheme.colors.messageFromOtherBackground } - val bubbleShape = bubbleShape() + // If we're running in UI test mode, we want to use a different shape to avoid + // this issue: https://issuetracker.google.com/issues/366255137 + val bubbleShape = if (LocalUiTestMode.current) RoundedCornerShape(12.dp) else bubbleShape() val radiusPx = (avatarRadius + SENDER_AVATAR_BORDER_WIDTH).toPx() val yOffsetPx = -(NEGATIVE_MARGIN_FOR_BUBBLE + avatarRadius).toPx() val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineEventTimestampView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineEventTimestampView.kt index a29c13ccd50..644cbf23108 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineEventTimestampView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineEventTimestampView.kt @@ -18,7 +18,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.invisibleToUser +import androidx.compose.ui.semantics.hideFromAccessibility import androidx.compose.ui.semantics.semantics import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp @@ -80,7 +80,7 @@ fun TimelineEventTimestampView( .clickable(isVerifiedUserSendFailure) { eventSink(TimelineEvents.ComputeVerifiedUserSendFailure(event)) } - .semantics { invisibleToUser() } + .semantics { hideFromAccessibility() } ) } @@ -95,7 +95,7 @@ fun TimelineEventTimestampView( .clickable { eventSink(TimelineEvents.ShowShieldDialog(shield)) } - .semantics { invisibleToUser() }, + .semantics { hideFromAccessibility() }, tint = shield.toIconColor(), ) Spacer(modifier = Modifier.width(4.dp)) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt index 1c9e8d5c2f0..55ee0d7a4f1 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt @@ -39,7 +39,7 @@ import androidx.compose.ui.platform.LocalViewConfiguration import androidx.compose.ui.platform.ViewConfiguration import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.clearAndSetSemantics -import androidx.compose.ui.semantics.invisibleToUser +import androidx.compose.ui.semantics.hideFromAccessibility import androidx.compose.ui.semantics.isTraversalGroup import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.traversalIndex @@ -439,7 +439,7 @@ private fun MessageSenderInformation( // Add external clickable modifier with no indicator so the touch target is larger than just the display name .clickable(onClick = onClick, enabled = true, interactionSource = remember { MutableInteractionSource() }, indication = null) .clearAndSetSemantics { - invisibleToUser() + hideFromAccessibility() } ) { Avatar( diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemReactionsView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemReactionsView.kt index 02cfa398308..07811b532d3 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemReactionsView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemReactionsView.kt @@ -16,7 +16,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.invisibleToUser +import androidx.compose.ui.semantics.hideFromAccessibility import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp @@ -43,7 +43,7 @@ fun TimelineItemReactionsView( var expanded: Boolean by rememberSaveable { mutableStateOf(false) } TimelineItemReactionsView( modifier = modifier.semantics { - invisibleToUser() + hideFromAccessibility() }, reactions = reactionsState.reactions, userCanSendReaction = userCanSendReaction, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt index 4ac09f106d7..c731005a579 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt @@ -36,7 +36,7 @@ import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.invisibleToUser +import androidx.compose.ui.semantics.hideFromAccessibility import androidx.compose.ui.semantics.semantics import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp @@ -136,7 +136,7 @@ fun TimelineItemVideoView( imageVector = CompoundIcons.PlaySolid(), contentDescription = stringResource(id = CommonStrings.a11y_play), colorFilter = ColorFilter.tint(Color.White), - modifier = Modifier.semantics { invisibleToUser() } + modifier = Modifier.semantics { hideFromAccessibility() } ) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/TimelineItemReadReceiptView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/TimelineItemReadReceiptView.kt index e7d80a9d6c2..8493bad7651 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/TimelineItemReadReceiptView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/TimelineItemReadReceiptView.kt @@ -26,7 +26,7 @@ import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.invisibleToUser +import androidx.compose.ui.semantics.hideFromAccessibility import androidx.compose.ui.semantics.testTag import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp @@ -59,7 +59,7 @@ fun TimelineItemReadReceiptView( if (renderReadReceipts) { ReadReceiptsRow( modifier = modifier.clearAndSetSemantics { - invisibleToUser() + hideFromAccessibility() } ) { ReadReceiptsAvatars( diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt index 9d378f7de76..4f6ac91890a 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt @@ -68,6 +68,7 @@ import io.element.android.tests.testutils.EventsRecorder import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.ensureCalledOnce import io.element.android.tests.testutils.pressBack +import io.element.android.tests.testutils.setSafeContent import kotlinx.collections.immutable.persistentListOf import org.junit.Rule import org.junit.Test @@ -567,11 +568,9 @@ private fun AndroidComposeTestRule.setMessa onJoinCallClick: () -> Unit = EnsureNeverCalled(), onViewAllPinnedMessagesClick: () -> Unit = EnsureNeverCalled(), ) { - setContent { + setSafeContent { // Cannot use the RichTextEditor, so simulate a LocalInspectionMode - CompositionLocalProvider( - LocalInspectionMode provides true - ) { + CompositionLocalProvider(LocalInspectionMode provides true) { MessagesView( state = state, onBackClick = onBackClick, diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/crypto/sendfailure/resolve/ResolveVerifiedUserSendFailureViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/crypto/sendfailure/resolve/ResolveVerifiedUserSendFailureViewTest.kt index a8190e8776d..6a83437758c 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/crypto/sendfailure/resolve/ResolveVerifiedUserSendFailureViewTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/crypto/sendfailure/resolve/ResolveVerifiedUserSendFailureViewTest.kt @@ -14,6 +14,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.tests.testutils.EventsRecorder import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.setSafeContent import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule @@ -54,7 +55,7 @@ class ResolveVerifiedUserSendFailureViewTest { private fun AndroidComposeTestRule.setResolveVerifiedUserSendFailureView( state: ResolveVerifiedUserSendFailureState, ) { - setContent { + setSafeContent { ResolveVerifiedUserSendFailureView(state = state) } } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListViewTest.kt index 4d66365a58f..5e9ad97f877 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListViewTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListViewTest.kt @@ -103,7 +103,7 @@ private fun AndroidComposeTestRule.setPinne onLinkClick: (Link) -> Unit = EnsureNeverCalledWithParam(), onLinkLongClick: (Link) -> Unit = EnsureNeverCalledWithParam(), ) { - setSafeContent { + setSafeContent(clearAndroidUiDispatcher = true) { PinnedMessagesListView( state = state, onBackClick = onBackClick, diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt index 110545dcae2..c4a2351990a 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt @@ -187,7 +187,7 @@ private fun AndroidComposeTestRule.setTimel onJoinCallClick: () -> Unit = EnsureNeverCalled(), forceJumpToBottomVisibility: Boolean = false, ) { - setSafeContent { + setSafeContent(clearAndroidUiDispatcher = true) { TimelineView( state = state, timelineProtectionState = timelineProtectionState, diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/RolesAndPermissionsViewTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/RolesAndPermissionsViewTest.kt index 9d793952b33..8aded9f698b 100644 --- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/RolesAndPermissionsViewTest.kt +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/RolesAndPermissionsViewTest.kt @@ -21,6 +21,7 @@ import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.ensureCalledOnce import io.element.android.tests.testutils.ensureCalledTimes import io.element.android.tests.testutils.pressBack +import io.element.android.tests.testutils.setSafeContent import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test @@ -159,14 +160,14 @@ class RolesAndPermissionsViewTest { private fun AndroidComposeTestRule.setRolesAndPermissionsView( state: RolesAndPermissionsState = aRolesAndPermissionsState( - eventSink = EventsRecorder(expectEvents = false), + eventSink = EventsRecorder(expectEvents = false), ), goBack: () -> Unit = EnsureNeverCalled(), openAdminList: () -> Unit = EnsureNeverCalled(), openModeratorList: () -> Unit = EnsureNeverCalled(), openPermissionScreens: () -> Unit = EnsureNeverCalled(), ) { - setContent { + setSafeContent { RolesAndPermissionsView( state = state, rolesAndPermissionsNavigator = object : RolesAndPermissionsNavigator { diff --git a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListContextMenuTest.kt b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListContextMenuTest.kt index 80b5a87c109..6becb39e7a8 100644 --- a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListContextMenuTest.kt +++ b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListContextMenuTest.kt @@ -8,6 +8,7 @@ package io.element.android.features.roomlist.impl import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.test.ext.junit.runners.AndroidJUnit4 import io.element.android.libraries.matrix.api.core.RoomId @@ -16,6 +17,7 @@ import io.element.android.tests.testutils.EnsureCalledOnceWithParam import io.element.android.tests.testutils.EnsureNeverCalledWithParam import io.element.android.tests.testutils.EventsRecorder import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.setSafeContent import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -28,15 +30,10 @@ class RoomListContextMenuTest { fun `clicking on Mark as read generates expected Events`() { val eventsRecorder = EventsRecorder() val contextMenu = aContextMenuShown(hasNewContent = true) - rule.setContent { - RoomListContextMenu( - contextMenu = contextMenu, - canReportRoom = false, - eventSink = eventsRecorder, - onRoomSettingsClick = EnsureNeverCalledWithParam(), - onReportRoomClick = EnsureNeverCalledWithParam(), - ) - } + rule.setRoomListContextMenu( + contextMenu = contextMenu, + eventSink = eventsRecorder, + ) rule.clickOn(R.string.screen_roomlist_mark_as_read) eventsRecorder.assertList( listOf( @@ -50,15 +47,10 @@ class RoomListContextMenuTest { fun `clicking on Mark as unread generates expected Events`() { val eventsRecorder = EventsRecorder() val contextMenu = aContextMenuShown(hasNewContent = false) - rule.setContent { - RoomListContextMenu( - contextMenu = contextMenu, - canReportRoom = false, - eventSink = eventsRecorder, - onRoomSettingsClick = EnsureNeverCalledWithParam(), - onReportRoomClick = EnsureNeverCalledWithParam(), - ) - } + rule.setRoomListContextMenu( + contextMenu = contextMenu, + eventSink = eventsRecorder, + ) rule.clickOn(R.string.screen_roomlist_mark_as_unread) eventsRecorder.assertList( listOf( @@ -72,15 +64,10 @@ class RoomListContextMenuTest { fun `clicking on Leave room generates expected Events`() { val eventsRecorder = EventsRecorder() val contextMenu = aContextMenuShown(isDm = false) - rule.setContent { - RoomListContextMenu( - contextMenu = contextMenu, - canReportRoom = false, - eventSink = eventsRecorder, - onRoomSettingsClick = EnsureNeverCalledWithParam(), - onReportRoomClick = EnsureNeverCalledWithParam(), - ) - } + rule.setRoomListContextMenu( + contextMenu = contextMenu, + eventSink = eventsRecorder, + ) rule.clickOn(CommonStrings.action_leave_room) eventsRecorder.assertList( listOf( @@ -95,15 +82,13 @@ class RoomListContextMenuTest { val eventsRecorder = EventsRecorder() val contextMenu = aContextMenuShown() val callback = EnsureCalledOnceWithParam(contextMenu.roomId, Unit) - rule.setContent { - RoomListContextMenu( - contextMenu = contextMenu, - canReportRoom = true, - eventSink = eventsRecorder, - onRoomSettingsClick = EnsureNeverCalledWithParam(), - onReportRoomClick = callback, - ) - } + rule.setRoomListContextMenu( + contextMenu = contextMenu, + canReportRoom = true, + eventSink = eventsRecorder, + onRoomSettingsClick = EnsureNeverCalledWithParam(), + onReportRoomClick = callback, + ) rule.clickOn(CommonStrings.action_report_room) eventsRecorder.assertSingle(RoomListEvents.HideContextMenu) callback.assertSuccess() @@ -114,15 +99,11 @@ class RoomListContextMenuTest { val eventsRecorder = EventsRecorder() val contextMenu = aContextMenuShown() val callback = EnsureCalledOnceWithParam(contextMenu.roomId, Unit) - rule.setContent { - RoomListContextMenu( - contextMenu = contextMenu, - canReportRoom = false, - eventSink = eventsRecorder, - onRoomSettingsClick = callback, - onReportRoomClick = EnsureNeverCalledWithParam(), - ) - } + rule.setRoomListContextMenu( + contextMenu = contextMenu, + eventSink = eventsRecorder, + onRoomSettingsClick = callback, + ) rule.clickOn(CommonStrings.common_settings) eventsRecorder.assertSingle(RoomListEvents.HideContextMenu) callback.assertSuccess() @@ -133,15 +114,11 @@ class RoomListContextMenuTest { val eventsRecorder = EventsRecorder() val contextMenu = aContextMenuShown(isDm = false, isFavorite = false) val callback = EnsureNeverCalledWithParam() - rule.setContent { - RoomListContextMenu( - contextMenu = contextMenu, - canReportRoom = false, - eventSink = eventsRecorder, - onRoomSettingsClick = callback, - onReportRoomClick = EnsureNeverCalledWithParam(), - ) - } + rule.setRoomListContextMenu( + contextMenu = contextMenu, + eventSink = eventsRecorder, + onRoomSettingsClick = callback, + ) rule.clickOn(CommonStrings.common_favourite) eventsRecorder.assertList( listOf( @@ -149,4 +126,22 @@ class RoomListContextMenuTest { ) ) } + + private fun AndroidComposeTestRule<*, *>.setRoomListContextMenu( + contextMenu: RoomListState.ContextMenu.Shown, + canReportRoom: Boolean = false, + eventSink: (RoomListEvents) -> Unit, + onRoomSettingsClick: (RoomId) -> Unit = EnsureNeverCalledWithParam(), + onReportRoomClick: (RoomId) -> Unit = EnsureNeverCalledWithParam(), + ) { + setSafeContent { + RoomListContextMenu( + contextMenu = contextMenu, + canReportRoom = canReportRoom, + onRoomSettingsClick = onRoomSettingsClick, + onReportRoomClick = onReportRoomClick, + eventSink = eventSink, + ) + } + } } diff --git a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListDeclineInviteMenuTest.kt b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListDeclineInviteMenuTest.kt index 0fdb5d7639b..8c75027ec51 100644 --- a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListDeclineInviteMenuTest.kt +++ b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListDeclineInviteMenuTest.kt @@ -16,6 +16,7 @@ import io.element.android.tests.testutils.EnsureCalledOnceWithParam import io.element.android.tests.testutils.EnsureNeverCalledWithParam import io.element.android.tests.testutils.EventsRecorder import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.setSafeContent import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -28,7 +29,7 @@ class RoomListDeclineInviteMenuTest { fun `clicking on decline emits the expected Events`() { val eventsRecorder = EventsRecorder() val menu = RoomListState.DeclineInviteMenu.Shown(roomSummary = aRoomListRoomSummary()) - rule.setContent { + rule.setSafeContent { RoomListDeclineInviteMenu( menu = menu, canReportRoom = false, @@ -49,7 +50,7 @@ class RoomListDeclineInviteMenuTest { fun `clicking on decline and block when canReportRoom=true, it emits the expected Events and callback`() { val eventsRecorder = EventsRecorder() val menu = RoomListState.DeclineInviteMenu.Shown(roomSummary = aRoomListRoomSummary()) - rule.setContent { + rule.setSafeContent { RoomListDeclineInviteMenu( menu = menu, canReportRoom = true, @@ -66,7 +67,7 @@ class RoomListDeclineInviteMenuTest { fun `clicking on decline and block when canReportRoom=false, it emits the expected Events`() { val eventsRecorder = EventsRecorder() val menu = RoomListState.DeclineInviteMenu.Shown(roomSummary = aRoomListRoomSummary()) - rule.setContent { + rule.setSafeContent { RoomListDeclineInviteMenu( menu = menu, canReportRoom = false, @@ -86,7 +87,7 @@ class RoomListDeclineInviteMenuTest { fun `clicking on cancel emits the expected Event`() { val eventsRecorder = EventsRecorder() val menu = RoomListState.DeclineInviteMenu.Shown(roomSummary = aRoomListRoomSummary()) - rule.setContent { + rule.setSafeContent { RoomListDeclineInviteMenu( menu = menu, canReportRoom = false, diff --git a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListViewTest.kt b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListViewTest.kt index 0242591f55b..299ef07578e 100644 --- a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListViewTest.kt +++ b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListViewTest.kt @@ -29,6 +29,7 @@ import io.element.android.tests.testutils.EventsRecorder import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.ensureCalledOnce import io.element.android.tests.testutils.ensureCalledOnceWithParam +import io.element.android.tests.testutils.setSafeContent import kotlinx.coroutines.ExperimentalCoroutinesApi import org.junit.Rule import org.junit.Test @@ -52,7 +53,7 @@ class RoomListViewTest { eventsRecorder.assertList( listOf( RoomListEvents.UpdateVisibleRange(IntRange.EMPTY), - RoomListEvents.UpdateVisibleRange(0 until 2), + RoomListEvents.UpdateVisibleRange(0..2), ) ) } @@ -273,7 +274,7 @@ private fun AndroidComposeTestRule.setRoomL onReportRoomClick: (RoomId) -> Unit = EnsureNeverCalledWithParam(), onDeclineInviteAndBlockUser: (RoomListRoomSummary) -> Unit = EnsureNeverCalledWithParam(), ) { - setContent { + setSafeContent { RoomListView( state = state, onRoomClick = onRoomClick, diff --git a/features/roommembermoderation/impl/src/test/kotlin/io/element/android/features/roommembermoderation/impl/RoomMemberModerationViewTest.kt b/features/roommembermoderation/impl/src/test/kotlin/io/element/android/features/roommembermoderation/impl/RoomMemberModerationViewTest.kt index d17b7579b62..fc1b8155622 100644 --- a/features/roommembermoderation/impl/src/test/kotlin/io/element/android/features/roommembermoderation/impl/RoomMemberModerationViewTest.kt +++ b/features/roommembermoderation/impl/src/test/kotlin/io/element/android/features/roommembermoderation/impl/RoomMemberModerationViewTest.kt @@ -22,6 +22,7 @@ import io.element.android.tests.testutils.EventsRecorder import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.ensureCalledOnceWithTwoParams import io.element.android.tests.testutils.pressTag +import io.element.android.tests.testutils.setSafeContent import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule @@ -217,7 +218,7 @@ private fun AndroidComposeTestRule.setRoomM state: InternalRoomMemberModerationState, onSelectAction: (ModerationAction, MatrixUser) -> Unit = EnsureNeverCalledWithTwoParams(), ) { - setContent { + setSafeContent { RoomMemberModerationView( state = state, onSelectAction = onSelectAction, diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/views/RecoveryKeyView.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/views/RecoveryKeyView.kt index ecd9cd522f4..9c24d463ba5 100644 --- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/views/RecoveryKeyView.kt +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/views/RecoveryKeyView.kt @@ -24,10 +24,12 @@ import androidx.compose.runtime.remember 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.autofill.ContentType import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentType +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType @@ -39,7 +41,6 @@ import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.features.securebackup.impl.R import io.element.android.features.securebackup.impl.tools.RecoveryKeyVisualTransformation -import io.element.android.libraries.designsystem.modifiers.autofill import io.element.android.libraries.designsystem.modifiers.clickableIfNotNull import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight @@ -186,10 +187,9 @@ private fun RecoveryKeyFormContent( modifier = Modifier .fillMaxWidth() .testTag(TestTags.recoveryKey) - .autofill( - autofillTypes = listOf(AutofillType.Password), - onFill = { onChange(it) }, - ), + .semantics { + contentType = ContentType.Password + }, minLines = 2, value = state.formattedRecoveryKey.orEmpty(), onValueChange = onChange, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 33cc6b8095a..0c9a34dac58 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -20,7 +20,7 @@ media3 = "1.7.1" camera = "1.4.2" # Compose -compose_bom = "2025.04.00" +compose_bom = "2025.04.01" composecompiler = "1.5.15" # Coroutines diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/Autofill.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/Autofill.kt deleted file mode 100644 index 5436a0330c7..00000000000 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/Autofill.kt +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright 2024 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.libraries.designsystem.modifiers - -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.Modifier -import androidx.compose.ui.autofill.AutofillNode -import androidx.compose.ui.autofill.AutofillType -import androidx.compose.ui.composed -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 - -@Suppress("ModifierComposed") -@OptIn(ExperimentalComposeUiApi::class) -fun Modifier.autofill(autofillTypes: List, onFill: (String) -> Unit) = composed { - val autofillNode = AutofillNode(autofillTypes, onFill = onFill) - LocalAutofillTree.current += autofillNode - - val autofill = LocalAutofill.current - - this - .onGloballyPositioned { - // Inform autofill framework of where our composable is so it can show the popup in the right place - autofillNode.boundingBox = it.boundsInWindow() - } - .onFocusChanged { - autofill?.run { - if (it.isFocused) { - requestAutofillForNode(autofillNode) - } else { - cancelAutofillForNode(autofillNode) - } - } - } -} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/BottomSheetScaffold.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/BottomSheetScaffold.kt index 5ac1dedb4a4..d4db8923a00 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/BottomSheetScaffold.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/BottomSheetScaffold.kt @@ -10,19 +10,18 @@ package io.element.android.libraries.designsystem.theme.components import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.PaddingValues import androidx.compose.material3.BottomSheetDefaults +import androidx.compose.material3.BottomSheetScaffoldState import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.contentColorFor +import androidx.compose.material3.rememberBottomSheetScaffoldState import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.unit.Dp -import io.element.android.libraries.designsystem.theme.components.bottomsheet.BottomSheetScaffoldState -import io.element.android.libraries.designsystem.theme.components.bottomsheet.CustomBottomSheetScaffold -import io.element.android.libraries.designsystem.theme.components.bottomsheet.rememberBottomSheetScaffoldState @Composable @ExperimentalMaterial3Api @@ -44,7 +43,7 @@ fun BottomSheetScaffold( contentColor: Color = contentColorFor(containerColor), content: @Composable (PaddingValues) -> Unit ) { - CustomBottomSheetScaffold( + androidx.compose.material3.BottomSheetScaffold( sheetContent = sheetContent, modifier = modifier, scaffoldState = scaffoldState, diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ModalBottomSheet.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ModalBottomSheet.kt index 51a6cd9ee18..e0d9c749ecf 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ModalBottomSheet.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ModalBottomSheet.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.BottomSheetDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme @@ -33,6 +34,7 @@ import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.PreviewGroup import io.element.android.libraries.designsystem.preview.sheetStateForPreview +import io.element.android.libraries.designsystem.utils.LocalUiTestMode import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -52,11 +54,14 @@ fun ModalBottomSheet( content: @Composable ColumnScope.() -> Unit, ) { val safeSheetState = if (LocalInspectionMode.current) sheetStateForPreview() else sheetState + // If we're running in UI test mode, we want to use a different shape to avoid + // this issue: https://issuetracker.google.com/issues/366255137 + val safeShape = if (LocalUiTestMode.current) RoundedCornerShape(12.dp) else shape androidx.compose.material3.ModalBottomSheet( onDismissRequest = onDismissRequest, modifier = modifier, sheetState = safeSheetState, - shape = shape, + shape = safeShape, containerColor = containerColor, contentColor = contentColor, tonalElevation = tonalElevation, diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/bottomsheet/CustomBottomSheetScaffold.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/bottomsheet/CustomBottomSheetScaffold.kt deleted file mode 100644 index a819d9770b6..00000000000 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/bottomsheet/CustomBottomSheetScaffold.kt +++ /dev/null @@ -1,516 +0,0 @@ -/* - * Copyright 2023, 2024 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial - * Please see LICENSE files in the repository root for full details. - */ -@file:OptIn(ExperimentalFoundationApi::class) - -package io.element.android.libraries.designsystem.theme.components.bottomsheet - -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.gestures.DraggableAnchors -import androidx.compose.foundation.gestures.Orientation -import androidx.compose.foundation.gestures.anchoredDraggable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.requiredHeightIn -import androidx.compose.foundation.layout.widthIn -import androidx.compose.material3.BottomSheetDefaults -import androidx.compose.material3.BottomSheetScaffold -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.SheetState -import androidx.compose.material3.SheetValue -import androidx.compose.material3.SheetValue.Expanded -import androidx.compose.material3.SheetValue.PartiallyExpanded -import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Surface -import androidx.compose.material3.contentColorFor -import androidx.compose.runtime.Composable -import androidx.compose.runtime.SideEffect -import androidx.compose.runtime.Stable -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.input.nestedscroll.NestedScrollConnection -import androidx.compose.ui.input.nestedscroll.NestedScrollSource -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.layout.SubcomposeLayout -import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.semantics.collapse -import androidx.compose.ui.semantics.dismiss -import androidx.compose.ui.semantics.expand -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.IntSize -import androidx.compose.ui.unit.Velocity -import kotlinx.coroutines.launch -import kotlin.math.abs -import kotlin.math.roundToInt - -// These are needed until https://issuetracker.google.com/issues/306464779 is fixed - -@Composable -@ExperimentalMaterial3Api -fun CustomBottomSheetScaffold( - sheetContent: @Composable ColumnScope.() -> Unit, - modifier: Modifier = Modifier, - scaffoldState: BottomSheetScaffoldState = rememberBottomSheetScaffoldState(), - sheetPeekHeight: Dp = BottomSheetDefaults.SheetPeekHeight, - sheetMaxWidth: Dp = BottomSheetDefaults.SheetMaxWidth, - sheetShape: Shape = BottomSheetDefaults.ExpandedShape, - sheetContainerColor: Color = Color.White, - sheetContentColor: Color = contentColorFor(sheetContainerColor), - sheetTonalElevation: Dp = BottomSheetDefaults.Elevation, - sheetShadowElevation: Dp = BottomSheetDefaults.Elevation, - sheetDragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() }, - sheetSwipeEnabled: Boolean = true, - topBar: @Composable (() -> Unit)? = null, - snackbarHost: @Composable (SnackbarHostState) -> Unit = { SnackbarHost(it) }, - containerColor: Color = MaterialTheme.colorScheme.surface, - contentColor: Color = contentColorFor(containerColor), - content: @Composable (PaddingValues) -> Unit -) { - val peekHeightPx = with(LocalDensity.current) { - sheetPeekHeight.roundToPx() - } - CustomBottomSheetScaffoldLayout( - modifier = modifier, - topBar = topBar, - body = content, - snackbarHost = { - snackbarHost(scaffoldState.snackbarHostState) - }, - sheetPeekHeight = sheetPeekHeight, - sheetOffset = { scaffoldState.bottomSheetState.requireOffset() }, - sheetState = scaffoldState.bottomSheetState, - containerColor = containerColor, - contentColor = contentColor, - bottomSheet = { layoutHeight -> - CustomStandardBottomSheet( - state = scaffoldState.bottomSheetState, - peekHeight = sheetPeekHeight, - sheetMaxWidth = sheetMaxWidth, - sheetSwipeEnabled = sheetSwipeEnabled, - calculateAnchors = { sheetSize -> - val sheetHeight = sheetSize.height - io.element.android.libraries.designsystem.theme.components.bottomsheet.DraggableAnchors { - if (!scaffoldState.bottomSheetState.skipPartiallyExpanded) { - PartiallyExpanded at (layoutHeight - peekHeightPx).toFloat() - } - if (sheetHeight != peekHeightPx) { - Expanded at maxOf(layoutHeight - sheetHeight, 0).toFloat() - } - if (!scaffoldState.bottomSheetState.skipHiddenState) { - SheetValue.Hidden at layoutHeight.toFloat() - } - } - }, - shape = sheetShape, - containerColor = sheetContainerColor, - contentColor = sheetContentColor, - tonalElevation = sheetTonalElevation, - shadowElevation = sheetShadowElevation, - dragHandle = sheetDragHandle, - content = sheetContent - ) - } - ) -} - -@SuppressWarnings("ModifierWithoutDefault") -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun CustomBottomSheetScaffoldLayout( - modifier: Modifier, - topBar: @Composable (() -> Unit)?, - body: @Composable (innerPadding: PaddingValues) -> Unit, - bottomSheet: @Composable (layoutHeight: Int) -> Unit, - snackbarHost: @Composable () -> Unit, - sheetPeekHeight: Dp, - sheetOffset: () -> Float, - sheetState: CustomSheetState, - containerColor: Color, - contentColor: Color, -) { - // b/291735717 Remove this once deprecated methods without density are removed - val density = LocalDensity.current - SideEffect { - sheetState.density = density - } - SubcomposeLayout { constraints -> - val layoutWidth = constraints.maxWidth - val layoutHeight = constraints.maxHeight - val looseConstraints = constraints.copy(minWidth = 0, minHeight = 0) - - val sheetPlaceable = subcompose(BottomSheetScaffoldLayoutSlot.Sheet) { - bottomSheet(layoutHeight) - }[0].measure(looseConstraints) - - val topBarPlaceable = topBar?.let { - subcompose(BottomSheetScaffoldLayoutSlot.TopBar) { topBar() }[0] - .measure(looseConstraints) - } - val topBarHeight = topBarPlaceable?.height ?: 0 - - val bodyConstraints = looseConstraints.copy(maxHeight = layoutHeight - topBarHeight) - val bodyPlaceable = subcompose(BottomSheetScaffoldLayoutSlot.Body) { - Surface( - modifier = modifier, - color = containerColor, - contentColor = contentColor, - ) { body(PaddingValues(bottom = sheetPeekHeight)) } - }[0].measure(bodyConstraints) - - val snackbarPlaceable = subcompose(BottomSheetScaffoldLayoutSlot.Snackbar, snackbarHost)[0] - .measure(looseConstraints) - - layout(layoutWidth, layoutHeight) { - val sheetOffsetY = sheetOffset().roundToInt() - val sheetOffsetX = Integer.max(0, (layoutWidth - sheetPlaceable.width) / 2) - - val snackbarOffsetX = (layoutWidth - snackbarPlaceable.width) / 2 - val snackbarOffsetY = when (sheetState.currentValue) { - SheetValue.PartiallyExpanded -> sheetOffsetY - snackbarPlaceable.height - SheetValue.Expanded, SheetValue.Hidden -> layoutHeight - snackbarPlaceable.height - } - - // Placement order is important for elevation - bodyPlaceable.placeRelative(0, topBarHeight) - topBarPlaceable?.placeRelative(0, 0) - sheetPlaceable.placeRelative(sheetOffsetX, sheetOffsetY) - snackbarPlaceable.placeRelative(snackbarOffsetX, snackbarOffsetY) - } - } -} - -private enum class BottomSheetScaffoldLayoutSlot { TopBar, Body, Sheet, Snackbar } - -@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) -@Composable -private fun CustomStandardBottomSheet( - state: CustomSheetState, - @Suppress("PrimitiveInLambda") - calculateAnchors: (sheetSize: IntSize) -> DraggableAnchors, - peekHeight: Dp, - sheetMaxWidth: Dp, - sheetSwipeEnabled: Boolean, - shape: Shape, - containerColor: Color, - contentColor: Color, - tonalElevation: Dp, - shadowElevation: Dp, - dragHandle: @Composable (() -> Unit)?, - content: @Composable ColumnScope.() -> Unit -) { - val scope = rememberCoroutineScope() - - val orientation = Orientation.Vertical - - Surface( - modifier = Modifier - .widthIn(max = sheetMaxWidth) - .fillMaxWidth() - .requiredHeightIn(min = peekHeight) - .apply { - if (sheetSwipeEnabled) { - nestedScroll( - remember(state.anchoredDraggableState) { - ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnection( - sheetState = state, - orientation = orientation, - onFling = { scope.launch { state.settle(it) } } - ) - } - ) - } - } - .anchoredDraggable( - state = state.anchoredDraggableState, - orientation = orientation, - enabled = sheetSwipeEnabled - ) - .onSizeChanged { layoutSize -> - val newAnchors = calculateAnchors(layoutSize) - val newTarget = when (state.anchoredDraggableState.targetValue) { - SheetValue.Hidden, SheetValue.PartiallyExpanded -> SheetValue.PartiallyExpanded - SheetValue.Expanded -> { - if (newAnchors.hasAnchorFor(SheetValue.Expanded)) SheetValue.Expanded else SheetValue.PartiallyExpanded - } - } - state.anchoredDraggableState.updateAnchors(newAnchors, newTarget) - }, - shape = shape, - color = containerColor, - contentColor = contentColor, - tonalElevation = tonalElevation, - shadowElevation = shadowElevation, - ) { - Column(Modifier.fillMaxWidth()) { - if (dragHandle != null) { - val partialExpandActionLabel = - "Partial Expand" - val dismissActionLabel = "Dismiss" - val expandActionLabel = "Expand" - Box( - Modifier - .align(Alignment.CenterHorizontally) - .semantics(mergeDescendants = true) { - with(state) { - // Provides semantics to interact with the bottomsheet if there is more - // than one anchor to swipe to and swiping is enabled. - if (anchoredDraggableState.anchors.size > 1 && sheetSwipeEnabled) { - if (currentValue == SheetValue.PartiallyExpanded) { - expand(expandActionLabel) { - scope.launch { expand() } - true - } - } else { - collapse(partialExpandActionLabel) { - scope.launch { partialExpand() } - true - } - } - if (!state.skipHiddenState) { - dismiss(dismissActionLabel) { - scope.launch { hide() } - true - } - } - } - } - }, - ) { - dragHandle() - } - } - content() - } - } -} - -/** - * [DraggableAnchorsConfig] stores a mutable configuration anchors, comprised of values of [T] and - * corresponding [Float] positions. This [DraggableAnchorsConfig] is used to construct an immutable - * [DraggableAnchors] instance later on. - */ -@ExperimentalFoundationApi -class DraggableAnchorsConfig { - internal val anchors = mutableMapOf() - - /** - * Set the anchor position for [this] anchor. - * - * @param position The anchor position. - */ - @Suppress("BuilderSetStyle") - infix fun T.at(position: Float) { - anchors[this] = position - } -} - -/** - * Create a new [DraggableAnchors] instance using a builder function. - * - * @param T The type of the anchor values. - * @param builder A function with a [DraggableAnchorsConfig] that offers APIs to configure anchors - * @return A new [DraggableAnchors] instance with the anchor positions set by the `builder` - * function. - */ -@OptIn(ExperimentalFoundationApi::class) -@ExperimentalMaterial3Api -@SuppressWarnings("FunctionName") -internal fun DraggableAnchors( - builder: DraggableAnchorsConfig.() -> Unit -): DraggableAnchors = MapDraggableAnchors(DraggableAnchorsConfig().apply(builder).anchors) - -private class MapDraggableAnchors(private val anchors: Map) : DraggableAnchors { - override fun positionOf(value: T): Float = anchors[value] ?: Float.NaN - override fun hasAnchorFor(value: T) = anchors.containsKey(value) - - override fun closestAnchor(position: Float): T? = anchors.minByOrNull { - abs(position - it.value) - }?.key - - override fun closestAnchor( - position: Float, - searchUpwards: Boolean - ): T? { - return anchors.minByOrNull { (_, anchor) -> - val delta = if (searchUpwards) anchor - position else position - anchor - if (delta < 0) Float.POSITIVE_INFINITY else delta - }?.key - } - - override fun minAnchor() = anchors.values.minOrNull() ?: Float.NaN - - override fun maxAnchor() = anchors.values.maxOrNull() ?: Float.NaN - - override val size: Int - get() = anchors.size - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other !is MapDraggableAnchors<*>) return false - - return anchors == other.anchors - } - - override fun forEach(block: (anchor: T, position: Float) -> Unit) { - for (anchor in anchors) { - block(anchor.key, anchor.value) - } - } - - override fun hashCode() = 31 * anchors.hashCode() - - override fun toString() = "MapDraggableAnchors($anchors)" -} - -@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) -@SuppressWarnings("FunctionName") -internal fun ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnection( - sheetState: CustomSheetState, - orientation: Orientation, - onFling: (velocity: Float) -> Unit -): NestedScrollConnection = object : NestedScrollConnection { - override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { - val delta = available.toFloat() - return if (delta < 0 && source == NestedScrollSource.UserInput) { - sheetState.anchoredDraggableState.dispatchRawDelta(delta).toOffset() - } else { - Offset.Zero - } - } - - override fun onPostScroll( - consumed: Offset, - available: Offset, - source: NestedScrollSource - ): Offset { - return if (source == NestedScrollSource.UserInput) { - sheetState.anchoredDraggableState.dispatchRawDelta(available.toFloat()).toOffset() - } else { - Offset.Zero - } - } - - override suspend fun onPreFling(available: Velocity): Velocity { - val toFling = available.toFloat() - val currentOffset = sheetState.requireOffset() - val minAnchor = sheetState.anchoredDraggableState.anchors.minAnchor() - return if (toFling < 0 && currentOffset > minAnchor) { - onFling(toFling) - // since we go to the anchor with tween settling, consume all for the best UX - available - } else { - Velocity.Zero - } - } - - override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { - onFling(available.toFloat()) - return available - } - - private fun Float.toOffset(): Offset = Offset( - x = if (orientation == Orientation.Horizontal) this else 0f, - y = if (orientation == Orientation.Vertical) this else 0f - ) - - @JvmName("velocityToFloat") - private fun Velocity.toFloat() = if (orientation == Orientation.Horizontal) x else y - - @JvmName("offsetToFloat") - private fun Offset.toFloat(): Float = if (orientation == Orientation.Horizontal) x else y -} - -/** - * State of the [BottomSheetScaffold] composable. - * - * @param bottomSheetState the state of the persistent bottom sheet - * @param snackbarHostState the [SnackbarHostState] used to show snackbars inside the scaffold - */ -@ExperimentalMaterial3Api -@Stable -@SuppressWarnings("UseDataClass") -class BottomSheetScaffoldState( - val bottomSheetState: CustomSheetState, - val snackbarHostState: SnackbarHostState -) - -/** - * Create and [remember] a [BottomSheetScaffoldState]. - * - * @param bottomSheetState the state of the standard bottom sheet. See - * [rememberStandardBottomSheetState] - * @param snackbarHostState the [SnackbarHostState] used to show snackbars inside the scaffold - */ -@Composable -@ExperimentalMaterial3Api -fun rememberBottomSheetScaffoldState( - bottomSheetState: CustomSheetState = rememberStandardBottomSheetState(), - snackbarHostState: SnackbarHostState = remember { SnackbarHostState() } -): BottomSheetScaffoldState { - return remember(bottomSheetState, snackbarHostState) { - BottomSheetScaffoldState( - bottomSheetState = bottomSheetState, - snackbarHostState = snackbarHostState - ) - } -} - -/** - * Create and [remember] a [SheetState] for [BottomSheetScaffold]. - * - * @param initialValue the initial value of the state. Should be either [PartiallyExpanded] or - * [Expanded] if [skipHiddenState] is true - * @param confirmValueChange optional callback invoked to confirm or veto a pending state change - * @param [skipHiddenState] whether Hidden state is skipped for [BottomSheetScaffold] - */ -@Composable -@ExperimentalMaterial3Api -fun rememberStandardBottomSheetState( - initialValue: SheetValue = PartiallyExpanded, - confirmValueChange: (SheetValue) -> Boolean = { true }, - skipHiddenState: Boolean = true, -) = rememberSheetState(false, confirmValueChange, initialValue, skipHiddenState) - -@Composable -@ExperimentalMaterial3Api -internal fun rememberSheetState( - skipPartiallyExpanded: Boolean = false, - confirmValueChange: (SheetValue) -> Boolean = { true }, - initialValue: SheetValue = SheetValue.Hidden, - skipHiddenState: Boolean = false, -): CustomSheetState { - val density = LocalDensity.current - return rememberSaveable( - skipPartiallyExpanded, - confirmValueChange, - saver = CustomSheetState.Saver( - skipPartiallyExpanded = skipPartiallyExpanded, - confirmValueChange = confirmValueChange, - density = density - ) - ) { - CustomSheetState( - skipPartiallyExpanded, - density, - initialValue, - confirmValueChange, - skipHiddenState - ) - } -} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/bottomsheet/CustomSheetState.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/bottomsheet/CustomSheetState.kt deleted file mode 100644 index 85507485e3f..00000000000 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/bottomsheet/CustomSheetState.kt +++ /dev/null @@ -1,303 +0,0 @@ -/* - * Copyright 2023, 2024 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.libraries.designsystem.theme.components.bottomsheet - -import androidx.compose.animation.core.SpringSpec -import androidx.compose.animation.core.exponentialDecay -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.gestures.animateTo -import androidx.compose.foundation.gestures.snapTo -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.SheetValue -import androidx.compose.material3.SheetValue.Expanded -import androidx.compose.material3.SheetValue.Hidden -import androidx.compose.material3.SheetValue.PartiallyExpanded -import androidx.compose.runtime.Stable -import androidx.compose.runtime.saveable.Saver -import androidx.compose.ui.unit.Density -import androidx.compose.ui.unit.dp -import kotlinx.coroutines.CancellationException - -@OptIn(ExperimentalFoundationApi::class) -@Stable -@ExperimentalMaterial3Api -class CustomSheetState -@Deprecated( - message = "This constructor is deprecated. " + - "Please use the constructor that provides a [Density]", - replaceWith = ReplaceWith( - "SheetState(" + - "skipPartiallyExpanded, LocalDensity.current, initialValue, " + - "confirmValueChange, skipHiddenState)" - ) -) -constructor( - internal val skipPartiallyExpanded: Boolean, - initialValue: SheetValue = Hidden, - confirmValueChange: (SheetValue) -> Boolean = { true }, - internal val skipHiddenState: Boolean = false, -) { - /** - * State of a sheet composable, such as [ModalBottomSheet] - * - * Contains states relating to its swipe position as well as animations between state values. - * - * @param skipPartiallyExpanded Whether the partially expanded state, if the sheet is large - * enough, should be skipped. If true, the sheet will always expand to the [Expanded] state and move - * to the [Hidden] state if available when hiding the sheet, either programmatically or by user - * interaction. - * @param density The density that this state can use to convert values to and from dp. - * @param initialValue The initial value of the state. - * @param confirmValueChange Optional callback invoked to confirm or veto a pending state change. - * @param skipHiddenState Whether the hidden state should be skipped. If true, the sheet will always - * expand to the [Expanded] state and move to the [PartiallyExpanded] if available, either - * programmatically or by user interaction. - */ - @ExperimentalMaterial3Api - @Suppress("Deprecation") - constructor( - skipPartiallyExpanded: Boolean, - density: Density, - initialValue: SheetValue = Hidden, - confirmValueChange: (SheetValue) -> Boolean = { true }, - skipHiddenState: Boolean = false, - ) : this(skipPartiallyExpanded, initialValue, confirmValueChange, skipHiddenState) { - this.density = density - } - - init { - if (skipPartiallyExpanded) { - require(initialValue != PartiallyExpanded) { - "The initial value must not be set to PartiallyExpanded if skipPartiallyExpanded " + - "is set to true." - } - } - if (skipHiddenState) { - require(initialValue != Hidden) { - "The initial value must not be set to Hidden if skipHiddenState is set to true." - } - } - } - - /** - * The current value of the state. - * - * If no swipe or animation is in progress, this corresponds to the state the bottom sheet is - * currently in. If a swipe or an animation is in progress, this corresponds the state the sheet - * was in before the swipe or animation started. - */ - - val currentValue: SheetValue get() = anchoredDraggableState.currentValue - - /** - * The target value of the bottom sheet state. - * - * If a swipe is in progress, this is the value that the sheet would animate to if the - * swipe finishes. If an animation is running, this is the target value of that animation. - * Finally, if no swipe or animation is in progress, this is the same as the [currentValue]. - */ - val targetValue: SheetValue get() = anchoredDraggableState.targetValue - - /** - * Whether the modal bottom sheet is visible. - */ - val isVisible: Boolean - get() = anchoredDraggableState.currentValue != Hidden - - /** - * Require the current offset (in pixels) of the bottom sheet. - * - * The offset will be initialized during the first measurement phase of the provided sheet - * content. - * - * These are the phases: - * Composition { -> Effects } -> Layout { Measurement -> Placement } -> Drawing - * - * During the first composition, an [IllegalStateException] is thrown. In subsequent - * compositions, the offset will be derived from the anchors of the previous pass. Always prefer - * accessing the offset from a LaunchedEffect as it will be scheduled to be executed the next - * frame, after layout. - * - * @throws IllegalStateException If the offset has not been initialized yet - */ - fun requireOffset(): Float = anchoredDraggableState.requireOffset() - - fun getOffset(): Float? = anchoredDraggableState.offset.takeIf { !it.isNaN() } - - /** - * Whether the sheet has an expanded state defined. - */ - - val hasExpandedState: Boolean - get() = anchoredDraggableState.anchors.hasAnchorFor(Expanded) - - /** - * Whether the modal bottom sheet has a partially expanded state defined. - */ - val hasPartiallyExpandedState: Boolean - get() = anchoredDraggableState.anchors.hasAnchorFor(PartiallyExpanded) - - /** - * Fully expand the bottom sheet with animation and suspend until it is fully expanded or - * animation has been cancelled. - * * - * @throws [CancellationException] if the animation is interrupted - */ - suspend fun expand() { - anchoredDraggableState.animateTo(Expanded) - } - - /** - * Animate the bottom sheet and suspend until it is partially expanded or animation has been - * cancelled. - * @throws [CancellationException] if the animation is interrupted - * @throws [IllegalStateException] if [skipPartiallyExpanded] is set to true - */ - suspend fun partialExpand() { - check(!skipPartiallyExpanded) { - "Attempted to animate to partial expanded when skipPartiallyExpanded was enabled. Set" + - " skipPartiallyExpanded to false to use this function." - } - animateTo(PartiallyExpanded) - } - - /** - * Expand the bottom sheet with animation and suspend until it is [PartiallyExpanded] if defined - * else [Expanded]. - * @throws [CancellationException] if the animation is interrupted - */ - suspend fun show() { - val targetValue = when { - hasPartiallyExpandedState -> PartiallyExpanded - else -> Expanded - } - animateTo(targetValue) - } - - /** - * Hide the bottom sheet with animation and suspend until it is fully hidden or animation has - * been cancelled. - * @throws [CancellationException] if the animation is interrupted - */ - suspend fun hide() { - check(!skipHiddenState) { - "Attempted to animate to hidden when skipHiddenState was enabled. Set skipHiddenState" + - " to false to use this function." - } - animateTo(Hidden) - } - - /** - * Animate to a [targetValue]. - * If the [targetValue] is not in the set of anchors, the [currentValue] will be updated to the - * [targetValue] without updating the offset. - * - * @throws CancellationException if the interaction interrupted by another interaction like a - * gesture interaction or another programmatic interaction like a [animateTo] or [snapTo] call. - * - * @param targetValue The target value of the animation - */ - @OptIn(ExperimentalFoundationApi::class) - internal suspend fun animateTo( - targetValue: SheetValue, - ) { - anchoredDraggableState.animateTo(targetValue) - } - - /** - * Snap to a [targetValue] without any animation. - * - * @throws CancellationException if the interaction interrupted by another interaction like a - * gesture interaction or another programmatic interaction like a [animateTo] or [snapTo] call. - * - * @param targetValue The target value of the animation - */ - @OptIn(ExperimentalFoundationApi::class) - internal suspend fun snapTo(targetValue: SheetValue) { - anchoredDraggableState.snapTo(targetValue) - } - - /** - * Find the closest anchor taking into account the velocity and settle at it with an animation. - */ - @OptIn(ExperimentalFoundationApi::class) - internal suspend fun settle(velocity: Float) { - anchoredDraggableState.settle(velocity) - } - - @OptIn(ExperimentalFoundationApi::class) - internal var anchoredDraggableState = androidx.compose.foundation.gestures.AnchoredDraggableState( - initialValue = initialValue, - snapAnimationSpec = AnchoredDraggableDefaults.SnapAnimationSpec, - decayAnimationSpec = AnchoredDraggableDefaults.DecayAnimationSpec, - confirmValueChange = confirmValueChange, - positionalThreshold = { with(requireDensity()) { 56.dp.toPx() } }, - velocityThreshold = { with(requireDensity()) { 125.dp.toPx() } } - ) - - @OptIn(ExperimentalFoundationApi::class) - internal val offset: Float? get() = anchoredDraggableState.offset - - internal var density: Density? = null - private fun requireDensity() = requireNotNull(density) { - "SheetState did not have a density attached. Are you using SheetState with " + - "BottomSheetScaffold or ModalBottomSheet component?" - } - - companion object { - /** - * The default [Saver] implementation for [SheetState]. - */ - @SuppressWarnings("FunctionName") - fun Saver( - skipPartiallyExpanded: Boolean, - confirmValueChange: (SheetValue) -> Boolean, - density: Density - ) = Saver( - save = { it.currentValue }, - restore = { savedValue -> - CustomSheetState(skipPartiallyExpanded, density, savedValue, confirmValueChange) - } - ) - - /** - * The default [Saver] implementation for [SheetState]. - */ - @Deprecated( - message = "This function is deprecated. Please use the overload where Density is" + - " provided.", - replaceWith = ReplaceWith( - "Saver(skipPartiallyExpanded, confirmValueChange, LocalDensity.current)" - ) - ) - @Suppress("Deprecation", "FunctionName") - fun Saver( - skipPartiallyExpanded: Boolean, - confirmValueChange: (SheetValue) -> Boolean - ) = Saver( - save = { it.currentValue }, - restore = { savedValue -> - CustomSheetState(skipPartiallyExpanded, savedValue, confirmValueChange) - } - ) - } -} - -@Stable -@ExperimentalMaterial3Api -internal object AnchoredDraggableDefaults { - /** - * The default animation used by [AnchoredDraggableState]. - */ - @ExperimentalMaterial3Api - val SnapAnimationSpec = SpringSpec() - - @ExperimentalMaterial3Api - val DecayAnimationSpec = exponentialDecay() -} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/LocalUiTestMode.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/LocalUiTestMode.kt new file mode 100644 index 00000000000..5c1288f0e72 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/LocalUiTestMode.kt @@ -0,0 +1,15 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.designsystem.utils + +import androidx.compose.runtime.staticCompositionLocalOf + +/** + * A composition local that indicates whether the app is running in UI test mode. + */ +val LocalUiTestMode = staticCompositionLocalOf { false } diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaDeleteConfirmationBottomSheetTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaDeleteConfirmationBottomSheetTest.kt index c71cdb8573c..1596104c46d 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaDeleteConfirmationBottomSheetTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaDeleteConfirmationBottomSheetTest.kt @@ -19,6 +19,7 @@ import io.element.android.tests.testutils.EnsureNeverCalledWithParam import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.ensureCalledOnce import io.element.android.tests.testutils.ensureCalledOnceWithParam +import io.element.android.tests.testutils.setSafeContent import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule @@ -60,7 +61,7 @@ private fun AndroidComposeTestRule.setMedia onDelete: (EventId) -> Unit = EnsureNeverCalledWithParam(), onDismiss: () -> Unit = EnsureNeverCalled(), ) { - setContent { + setSafeContent { MediaDeleteConfirmationBottomSheet( state = state, onDelete = onDelete, diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaDetailsBottomSheetTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaDetailsBottomSheetTest.kt index b74ae07b8b2..1289d736721 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaDetailsBottomSheetTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaDetailsBottomSheetTest.kt @@ -18,6 +18,7 @@ import io.element.android.tests.testutils.EnsureNeverCalled import io.element.android.tests.testutils.EnsureNeverCalledWithParam import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.ensureCalledOnceWithParam +import io.element.android.tests.testutils.setSafeContent import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule @@ -100,7 +101,7 @@ private fun AndroidComposeTestRule.setMedia onDelete: (EventId) -> Unit = EnsureNeverCalledWithParam(), onDismiss: () -> Unit = EnsureNeverCalled(), ) { - setContent { + setSafeContent { MediaDetailsBottomSheet( state = state, onViewInTimeline = onViewInTimeline, diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerViewTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerViewTest.kt index ba9881a7862..74d83b733ba 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerViewTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerViewTest.kt @@ -26,6 +26,7 @@ import io.element.android.tests.testutils.EventsRecorder import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.ensureCalledOnce import io.element.android.tests.testutils.pressBack +import io.element.android.tests.testutils.setSafeContent import io.mockk.mockk import org.junit.Rule import org.junit.Test @@ -249,7 +250,7 @@ private fun AndroidComposeTestRule.setMedia state: MediaViewerState, onBackClick: () -> Unit = EnsureNeverCalled(), ) { - setContent { + setSafeContent { MediaViewerView( state = state, audioFocus = null, diff --git a/tests/testutils/build.gradle.kts b/tests/testutils/build.gradle.kts index a7b81808f9c..8a7730095a3 100644 --- a/tests/testutils/build.gradle.kts +++ b/tests/testutils/build.gradle.kts @@ -23,6 +23,7 @@ dependencies { implementation(projects.libraries.androidutils) implementation(projects.libraries.architecture) implementation(projects.libraries.core) + implementation(projects.libraries.designsystem) implementation(projects.libraries.uiStrings) implementation(projects.services.toolbox.api) implementation(libs.test.turbine) diff --git a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/RobolectricDispatcherCleaner.kt b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/RobolectricDispatcherCleaner.kt index 0723d85d650..620b5def406 100644 --- a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/RobolectricDispatcherCleaner.kt +++ b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/RobolectricDispatcherCleaner.kt @@ -9,7 +9,9 @@ package io.element.android.tests.testutils import androidx.activity.ComponentActivity import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import io.element.android.libraries.designsystem.utils.LocalUiTestMode import org.junit.Assert.assertFalse import org.junit.rules.TestRule import kotlin.coroutines.CoroutineContext @@ -49,7 +51,16 @@ object RobolectricDispatcherCleaner { } } -fun AndroidComposeTestRule.setSafeContent(content: @Composable () -> Unit) { - RobolectricDispatcherCleaner.clearAndroidUiDispatcher() - setContent(content) +fun AndroidComposeTestRule.setSafeContent( + clearAndroidUiDispatcher: Boolean = false, + content: @Composable () -> Unit, +) { + if (clearAndroidUiDispatcher) { + RobolectricDispatcherCleaner.clearAndroidUiDispatcher() + } + setContent { + CompositionLocalProvider(LocalUiTestMode provides true) { + content() + } + } } diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.theme.components.previews_DatePickerDark_DateTime_pickers_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.theme.components.previews_DatePickerDark_DateTime_pickers_en.png index 37b234801f4..5555e6bb022 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.theme.components.previews_DatePickerDark_DateTime_pickers_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.theme.components.previews_DatePickerDark_DateTime_pickers_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ea4f3ed733159e0413383d8994943b5b7b3100160728138b7014a9567f665021 -size 30632 +oid sha256:3ecb9e2af8ad8221225e71463a1da8589ff1a6c9ba393f636c2dbec08fae513b +size 30991 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.theme.components.previews_DatePickerLight_DateTime_pickers_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.theme.components.previews_DatePickerLight_DateTime_pickers_en.png index f9577523847..897b8d763e9 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.theme.components.previews_DatePickerLight_DateTime_pickers_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.theme.components.previews_DatePickerLight_DateTime_pickers_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:657c14f3e42751aefbac5fc0b7d94462d2b90da1707ab7ce7997f5db1b29c6d2 -size 31013 +oid sha256:f7f456872a423c0edeb87e5a9fbc7e73896f3cb75609a64c4acb959ffd77e72d +size 31404 diff --git a/tools/detekt/detekt.yml b/tools/detekt/detekt.yml index 71187feced2..9af20180c6d 100644 --- a/tools/detekt/detekt.yml +++ b/tools/detekt/detekt.yml @@ -230,6 +230,7 @@ Compose: - LocalMentionSpanUpdater - LocalAnalyticsService - LocalBuildMeta + - LocalUiTestMode CompositionLocalNaming: active: true ContentEmitterReturningValues: