fix(deps): update dependency androidx.compose:compose-bom to v2025.04.01 (#4631)
* fix(deps): update dependency androidx.compose:compose-bom to v2025.04.01 * Fix autofill deprecations * Adapt our custom BottomSheetState and scaffold to the new APIs * Get rid of all the custom bottom sheet implementation It doesn't seem to be needed anymore 🎉 * Replace `semantics { invisibleToUser() }` with `hideFromAccessibility()` * Update screenshots * Add commit and cancel callbacks for autofill on the login view * Fix broken tests caused mainly by https://issuetracker.google.com/issues/366255137 Add `LocalUiTestMode` composition local and helper functions. * Remove dependency that caused a new license to need to be approved * Let setSafeContent handle setting the value for LocalUiTestMode * Fix broken test * Apply fix to RoomMemberModerationViewTest and RoomListDeclineInviteMenuTest --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Jorge Martín <jorgem@element.io> Co-authored-by: ElementBot <android@element.io> Co-authored-by: Benoit Marty <benoit@matrix.org>
This commit is contained in:
parent
b59ddeb652
commit
7bb1e24ff5
37 changed files with 189 additions and 1012 deletions
|
|
@ -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 <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setJoinRoomByAddressView(
|
||||
state: JoinRoomByAddressState,
|
||||
) {
|
||||
setContent {
|
||||
setSafeContent {
|
||||
JoinRoomByAddressView(state = state)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.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,
|
||||
|
|
|
|||
|
|
@ -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 <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setResolveVerifiedUserSendFailureView(
|
||||
state: ResolveVerifiedUserSendFailureState,
|
||||
) {
|
||||
setContent {
|
||||
setSafeContent {
|
||||
ResolveVerifiedUserSendFailureView(state = state)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -103,7 +103,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setPinne
|
|||
onLinkClick: (Link) -> Unit = EnsureNeverCalledWithParam(),
|
||||
onLinkLongClick: (Link) -> Unit = EnsureNeverCalledWithParam(),
|
||||
) {
|
||||
setSafeContent {
|
||||
setSafeContent(clearAndroidUiDispatcher = true) {
|
||||
PinnedMessagesListView(
|
||||
state = state,
|
||||
onBackClick = onBackClick,
|
||||
|
|
|
|||
|
|
@ -187,7 +187,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setTimel
|
|||
onJoinCallClick: () -> Unit = EnsureNeverCalled(),
|
||||
forceJumpToBottomVisibility: Boolean = false,
|
||||
) {
|
||||
setSafeContent {
|
||||
setSafeContent(clearAndroidUiDispatcher = true) {
|
||||
TimelineView(
|
||||
state = state,
|
||||
timelineProtectionState = timelineProtectionState,
|
||||
|
|
|
|||
|
|
@ -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 <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.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 {
|
||||
|
|
|
|||
|
|
@ -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<RoomListEvents>()
|
||||
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<RoomListEvents>()
|
||||
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<RoomListEvents>()
|
||||
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<RoomListEvents>()
|
||||
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<RoomListEvents>()
|
||||
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<RoomListEvents>()
|
||||
val contextMenu = aContextMenuShown(isDm = false, isFavorite = false)
|
||||
val callback = EnsureNeverCalledWithParam<RoomId>()
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<RoomListEvents>()
|
||||
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<RoomListEvents>()
|
||||
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<RoomListEvents>()
|
||||
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<RoomListEvents>()
|
||||
val menu = RoomListState.DeclineInviteMenu.Shown(roomSummary = aRoomListRoomSummary())
|
||||
rule.setContent {
|
||||
rule.setSafeContent {
|
||||
RoomListDeclineInviteMenu(
|
||||
menu = menu,
|
||||
canReportRoom = false,
|
||||
|
|
|
|||
|
|
@ -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 <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRoomL
|
|||
onReportRoomClick: (RoomId) -> Unit = EnsureNeverCalledWithParam(),
|
||||
onDeclineInviteAndBlockUser: (RoomListRoomSummary) -> Unit = EnsureNeverCalledWithParam(),
|
||||
) {
|
||||
setContent {
|
||||
setSafeContent {
|
||||
RoomListView(
|
||||
state = state,
|
||||
onRoomClick = onRoomClick,
|
||||
|
|
|
|||
|
|
@ -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 <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRoomM
|
|||
state: InternalRoomMemberModerationState,
|
||||
onSelectAction: (ModerationAction, MatrixUser) -> Unit = EnsureNeverCalledWithTwoParams(),
|
||||
) {
|
||||
setContent {
|
||||
setSafeContent {
|
||||
RoomMemberModerationView(
|
||||
state = state,
|
||||
onSelectAction = onSelectAction,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<AutofillType>, 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<SheetValue>,
|
||||
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<T> {
|
||||
internal val anchors = mutableMapOf<T, Float>()
|
||||
|
||||
/**
|
||||
* 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 <T : Any> DraggableAnchors(
|
||||
builder: DraggableAnchorsConfig<T>.() -> Unit
|
||||
): DraggableAnchors<T> = MapDraggableAnchors(DraggableAnchorsConfig<T>().apply(builder).anchors)
|
||||
|
||||
private class MapDraggableAnchors<T>(private val anchors: Map<T, Float>) : DraggableAnchors<T> {
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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<CustomSheetState, SheetValue>(
|
||||
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<CustomSheetState, SheetValue>(
|
||||
save = { it.currentValue },
|
||||
restore = { savedValue ->
|
||||
CustomSheetState(skipPartiallyExpanded, savedValue, confirmValueChange)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Stable
|
||||
@ExperimentalMaterial3Api
|
||||
internal object AnchoredDraggableDefaults {
|
||||
/**
|
||||
* The default animation used by [AnchoredDraggableState].
|
||||
*/
|
||||
@ExperimentalMaterial3Api
|
||||
val SnapAnimationSpec = SpringSpec<Float>()
|
||||
|
||||
@ExperimentalMaterial3Api
|
||||
val DecayAnimationSpec = exponentialDecay<Float>()
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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 <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setMedia
|
|||
onDelete: (EventId) -> Unit = EnsureNeverCalledWithParam(),
|
||||
onDismiss: () -> Unit = EnsureNeverCalled(),
|
||||
) {
|
||||
setContent {
|
||||
setSafeContent {
|
||||
MediaDeleteConfirmationBottomSheet(
|
||||
state = state,
|
||||
onDelete = onDelete,
|
||||
|
|
|
|||
|
|
@ -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 <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setMedia
|
|||
onDelete: (EventId) -> Unit = EnsureNeverCalledWithParam(),
|
||||
onDismiss: () -> Unit = EnsureNeverCalled(),
|
||||
) {
|
||||
setContent {
|
||||
setSafeContent {
|
||||
MediaDetailsBottomSheet(
|
||||
state = state,
|
||||
onViewInTimeline = onViewInTimeline,
|
||||
|
|
|
|||
|
|
@ -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 <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setMedia
|
|||
state: MediaViewerState,
|
||||
onBackClick: () -> Unit = EnsureNeverCalled(),
|
||||
) {
|
||||
setContent {
|
||||
setSafeContent {
|
||||
MediaViewerView(
|
||||
state = state,
|
||||
audioFocus = null,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 <R : TestRule, A : ComponentActivity> AndroidComposeTestRule<R, A>.setSafeContent(content: @Composable () -> Unit) {
|
||||
RobolectricDispatcherCleaner.clearAndroidUiDispatcher()
|
||||
setContent(content)
|
||||
fun <R : TestRule, A : ComponentActivity> AndroidComposeTestRule<R, A>.setSafeContent(
|
||||
clearAndroidUiDispatcher: Boolean = false,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
if (clearAndroidUiDispatcher) {
|
||||
RobolectricDispatcherCleaner.clearAndroidUiDispatcher()
|
||||
}
|
||||
setContent {
|
||||
CompositionLocalProvider(LocalUiTestMode provides true) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:ea4f3ed733159e0413383d8994943b5b7b3100160728138b7014a9567f665021
|
||||
size 30632
|
||||
oid sha256:3ecb9e2af8ad8221225e71463a1da8589ff1a6c9ba393f636c2dbec08fae513b
|
||||
size 30991
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:657c14f3e42751aefbac5fc0b7d94462d2b90da1707ab7ce7997f5db1b29c6d2
|
||||
size 31013
|
||||
oid sha256:f7f456872a423c0edeb87e5a9fbc7e73896f3cb75609a64c4acb959ffd77e72d
|
||||
size 31404
|
||||
|
|
|
|||
|
|
@ -230,6 +230,7 @@ Compose:
|
|||
- LocalMentionSpanUpdater
|
||||
- LocalAnalyticsService
|
||||
- LocalBuildMeta
|
||||
- LocalUiTestMode
|
||||
CompositionLocalNaming:
|
||||
active: true
|
||||
ContentEmitterReturningValues:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue