diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/DismissibleBanner.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/DismissibleBanner.kt deleted file mode 100644 index 1c743ec8dd..0000000000 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/DismissibleBanner.kt +++ /dev/null @@ -1,78 +0,0 @@ -/* - * Copyright (c) 2026 Element Creations 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.features.home.impl.components - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.EnterTransition -import androidx.compose.animation.core.MutableTransitionState -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeOut -import androidx.compose.animation.shrinkVertically -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshotFlow -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import kotlinx.coroutines.flow.first - -/** - * Wraps a banner of the room list so that it shrinks and fades out when it is dismissed. - * [onDismiss] is invoked once the animation is over, so that the banner is removed from the list - * when its height is 0, and the items below it are not moved abruptly. - * - * @param onDismiss invoked when the banner has been dismissed and the animation is over. - * @param modifier the modifier to apply to the banner container. - * @param content the banner, which must invoke the provided lambda when the user dismisses it. - */ -@Composable -internal fun DismissibleBanner( - onDismiss: () -> Unit, - modifier: Modifier = Modifier, - content: @Composable (onDismissClick: () -> Unit) -> Unit, -) { - val visibleState = remember { MutableTransitionState(true) } - val latestOnDismiss by rememberUpdatedState(onDismiss) - var isDismissNotified by remember { mutableStateOf(false) } - fun notifyDismiss() { - if (!isDismissNotified) { - isDismissNotified = true - latestOnDismiss() - } - } - LaunchedEffect(visibleState) { - snapshotFlow { visibleState.isIdle && !visibleState.currentState }.first { it } - notifyDismiss() - } - DisposableEffect(visibleState) { - onDispose { - // The item can be disposed during the animation, for instance if the user scrolls the list - if (!visibleState.targetState) { - notifyDismiss() - } - } - } - AnimatedVisibility( - visibleState = visibleState, - modifier = modifier, - enter = EnterTransition.None, - exit = shrinkVertically( - animationSpec = tween(), - shrinkTowards = Alignment.CenterVertically, - ) + fadeOut( - animationSpec = tween(), - ), - ) { - content { visibleState.targetState = false } - } -} diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListContentView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListContentView.kt index 09dc51d68f..02288eb8b7 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListContentView.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomListContentView.kt @@ -8,6 +8,12 @@ package io.element.android.features.home.impl.components +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -15,7 +21,9 @@ import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState @@ -24,6 +32,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.PreviewParameter @@ -39,10 +48,11 @@ import io.element.android.features.home.impl.filters.aRoomListFiltersState import io.element.android.features.home.impl.filters.selection.FilterSelectionState import io.element.android.features.home.impl.model.RoomListRoomSummary import io.element.android.features.home.impl.model.RoomSummaryDisplayType +import io.element.android.features.home.impl.roomlist.RoomListBanner import io.element.android.features.home.impl.roomlist.RoomListContentState import io.element.android.features.home.impl.roomlist.RoomListContentStatePreviewParam import io.element.android.features.home.impl.roomlist.RoomListEvent -import io.element.android.features.home.impl.roomlist.SecurityBannerState +import io.element.android.features.home.impl.roomlist.toRoomListBanner import io.element.android.features.home.impl.spacefilters.SpaceFiltersState import io.element.android.features.home.impl.spacefilters.anUnselectedSpaceFiltersState import io.element.android.libraries.designsystem.preview.ElementPreview @@ -160,30 +170,20 @@ private fun EmptyView( }, modifier = Modifier.align(Alignment.Center), ) - Box { - when (state.securityBannerState) { - SecurityBannerState.SetUpRecovery -> { - DismissibleBanner( - onDismiss = { eventSink(RoomListEvent.DismissBanner) }, - ) { onDismissClick -> - SetUpRecoveryKeyBanner( - onContinueClick = onSetUpRecoveryClick, - onDismissClick = onDismissClick, - ) + RoomListBannerContainer( + banner = state.securityBannerState.toRoomListBanner(), + ) { banner -> + RoomListBannerView( + banner = banner, + onActionClick = { + when (banner) { + RoomListBanner.SetUpRecovery -> onSetUpRecoveryClick() + RoomListBanner.RecoveryKeyConfirmation -> onConfirmRecoveryKeyClick() + else -> Unit } - } - SecurityBannerState.RecoveryKeyConfirmation -> { - DismissibleBanner( - onDismiss = { eventSink(RoomListEvent.DismissBanner) }, - ) { onDismissClick -> - ConfirmRecoveryKeyBanner( - onContinueClick = onConfirmRecoveryKeyClick, - onDismissClick = onDismissClick, - ) - } - } - SecurityBannerState.None -> Unit - } + }, + onDismissClick = { eventSink(RoomListEvent.DismissBanner) }, + ) } } } @@ -237,6 +237,10 @@ private fun RoomsViewList( lazyListState: LazyListState, modifier: Modifier = Modifier, ) { + val banner = state.toRoomListBanner() + // The banner slot must never be 0px high, else the LazyColumn considers that it is not visible and it is + // removed from the composition. A new banner would then be added above the visible area of the list. + val bannerSlotMinHeight = with(LocalDensity.current) { 1.toDp() } OnVisibleRangeChangeEffect(lazyListState) { visibleRange -> // The first item of the list is the banner slot, remove it to get the range of the rooms val roomsRange = (visibleRange.first - 1).coerceAtLeast(0).. + RoomListBannerView( + banner = displayedBanner, + onActionClick = { + when (displayedBanner) { + RoomListBanner.SetUpRecovery -> onSetUpRecoveryClick() + RoomListBanner.RecoveryKeyConfirmation -> onConfirmRecoveryKeyClick() + RoomListBanner.FullScreenIntentPermission -> + state.fullScreenIntentPermissionsState.eventSink(FullScreenIntentPermissionsEvent.OpenSettings) + RoomListBanner.BatteryOptimization -> + state.batteryOptimizationState.eventSink(BatteryOptimizationEvent.RequestDisableOptimizations) + RoomListBanner.NewNotificationSound, + RoomListBanner.SwipeAvatar -> Unit + } + }, + onDismissClick = { + when (displayedBanner) { + RoomListBanner.SetUpRecovery, + RoomListBanner.RecoveryKeyConfirmation -> eventSink(RoomListEvent.DismissBanner) + RoomListBanner.FullScreenIntentPermission -> + state.fullScreenIntentPermissionsState.eventSink(FullScreenIntentPermissionsEvent.Dismiss) + RoomListBanner.BatteryOptimization -> state.batteryOptimizationState.eventSink(BatteryOptimizationEvent.Dismiss) + RoomListBanner.NewNotificationSound -> eventSink(RoomListEvent.DismissNewNotificationSoundBanner) + RoomListBanner.SwipeAvatar -> eventSink(RoomListEvent.DismissSwipeAvatarBanner) + } + }, + ) + } } // Note: do not use a key for the LazyColumn, or the scroll will not behave as expected if a room // is moved to the top of the list. @@ -281,79 +310,73 @@ private fun RoomsViewList( } } +/** + * Displays the [banner] at the top of the room list, and animates every change: when the banner is + * dismissed, when it is replaced by another banner, and when a banner appears. + * The size of the slot is animated, so that the items below it are not moved abruptly. + * + * @param banner the banner to display, or null if there is no banner to display. + * @param modifier the modifier to apply to the slot. + * @param content renders the given banner. + */ @Composable -private fun RoomsViewBanner( - state: RoomListContentState.Rooms, - eventSink: (RoomListEvent) -> Unit, - onSetUpRecoveryClick: () -> Unit, - onConfirmRecoveryKeyClick: () -> Unit, +private fun RoomListBannerContainer( + banner: RoomListBanner?, + modifier: Modifier = Modifier, + content: @Composable (RoomListBanner) -> Unit, ) { - when (state.securityBannerState) { - SecurityBannerState.SetUpRecovery -> { - DismissibleBanner( - onDismiss = { eventSink(RoomListEvent.DismissBanner) }, - ) { onDismissClick -> - SetUpRecoveryKeyBanner( - onContinueClick = onSetUpRecoveryClick, - onDismissClick = onDismissClick, - ) - } - } - SecurityBannerState.RecoveryKeyConfirmation -> { - DismissibleBanner( - onDismiss = { eventSink(RoomListEvent.DismissBanner) }, - ) { onDismissClick -> - ConfirmRecoveryKeyBanner( - onContinueClick = onConfirmRecoveryKeyClick, - onDismissClick = onDismissClick, - ) - } - } - // Banner precedence (top-to-bottom): full-screen-intent > battery-optimization > - // new-notification-sound > swipe-avatar > sound-unavailable. At most one renders at a time. - SecurityBannerState.None -> when { - state.fullScreenIntentPermissionsState.shouldDisplayBanner -> { - DismissibleBanner( - onDismiss = { state.fullScreenIntentPermissionsState.eventSink(FullScreenIntentPermissionsEvent.Dismiss) }, - ) { onDismissClick -> - FullScreenIntentPermissionBanner( - onActionClick = { state.fullScreenIntentPermissionsState.eventSink(FullScreenIntentPermissionsEvent.OpenSettings) }, - onDismissClick = onDismissClick, - ) - } - } - state.batteryOptimizationState.shouldDisplayBanner -> { - DismissibleBanner( - onDismiss = { state.batteryOptimizationState.eventSink(BatteryOptimizationEvent.Dismiss) }, - ) { onDismissClick -> - BatteryOptimizationBanner( - onActionClick = { state.batteryOptimizationState.eventSink(BatteryOptimizationEvent.RequestDisableOptimizations) }, - onDismissClick = onDismissClick, - ) - } - } - state.showNewNotificationSoundBanner -> { - DismissibleBanner( - onDismiss = { eventSink(RoomListEvent.DismissNewNotificationSoundBanner) }, - ) { onDismissClick -> - NewNotificationSoundBanner( - onDismissClick = onDismissClick, - ) - } - } - state.showSwipeAvatarBanner -> { - DismissibleBanner( - onDismiss = { eventSink(RoomListEvent.DismissSwipeAvatarBanner) }, - ) { onDismissClick -> - SwipeAvatarBanner( - onDismissClick = onDismissClick, - ) - } - } + AnimatedContent( + targetState = banner, + // Fill the width so that only the height is animated + modifier = modifier.fillMaxWidth(), + transitionSpec = { + fadeIn(tween(durationMillis = HALF_DURATION_MILLIS, delayMillis = HALF_DURATION_MILLIS)) togetherWith + fadeOut(tween(durationMillis = HALF_DURATION_MILLIS)) using + SizeTransform { _, _ -> tween(durationMillis = 2 * HALF_DURATION_MILLIS) } + }, + contentAlignment = Alignment.Center, + label = "RoomListBannerContainer", + ) { targetBanner -> + if (targetBanner != null) { + content(targetBanner) } } } +private const val HALF_DURATION_MILLIS = 150 + +@Composable +private fun RoomListBannerView( + banner: RoomListBanner, + onActionClick: () -> Unit, + onDismissClick: () -> Unit, +) { + when (banner) { + RoomListBanner.SetUpRecovery -> SetUpRecoveryKeyBanner( + onContinueClick = onActionClick, + onDismissClick = onDismissClick, + ) + RoomListBanner.RecoveryKeyConfirmation -> ConfirmRecoveryKeyBanner( + onContinueClick = onActionClick, + onDismissClick = onDismissClick, + ) + RoomListBanner.FullScreenIntentPermission -> FullScreenIntentPermissionBanner( + onActionClick = onActionClick, + onDismissClick = onDismissClick, + ) + RoomListBanner.BatteryOptimization -> BatteryOptimizationBanner( + onActionClick = onActionClick, + onDismissClick = onDismissClick, + ) + RoomListBanner.NewNotificationSound -> NewNotificationSoundBanner( + onDismissClick = onDismissClick, + ) + RoomListBanner.SwipeAvatar -> SwipeAvatarBanner( + onDismissClick = onDismissClick, + ) + } +} + @Composable private fun EmptyViewForFilterStates( selectedFilters: ImmutableList, diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListState.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListState.kt index 6d3d85ab56..f77695399b 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListState.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListState.kt @@ -61,6 +61,18 @@ enum class SecurityBannerState { RecoveryKeyConfirmation, } +/** + * The banners which can be displayed at the top of the room list. At most one banner is displayed at a time. + */ +internal enum class RoomListBanner { + SetUpRecovery, + RecoveryKeyConfirmation, + FullScreenIntentPermission, + BatteryOptimization, + NewNotificationSound, + SwipeAvatar, +} + @Immutable sealed interface RoomListContentState { data class Skeleton(val count: Int) : RoomListContentState @@ -80,3 +92,24 @@ sealed interface RoomListContentState { val showAllActivity: Boolean, ) : RoomListContentState } + +internal fun SecurityBannerState.toRoomListBanner(): RoomListBanner? = when (this) { + SecurityBannerState.SetUpRecovery -> RoomListBanner.SetUpRecovery + SecurityBannerState.RecoveryKeyConfirmation -> RoomListBanner.RecoveryKeyConfirmation + SecurityBannerState.None -> null +} + +/** + * Banner precedence (top-to-bottom): security > full-screen-intent > battery-optimization > + * new-notification-sound > swipe-avatar. At most one renders at a time. + */ +internal fun RoomListContentState.Rooms.toRoomListBanner(): RoomListBanner? { + return securityBannerState.toRoomListBanner() ?: when { + fullScreenIntentPermissionsState.shouldDisplayBanner -> RoomListBanner.FullScreenIntentPermission + batteryOptimizationState.shouldDisplayBanner -> RoomListBanner.BatteryOptimization + showNewNotificationSoundBanner -> RoomListBanner.NewNotificationSound + showSwipeAvatarBanner -> RoomListBanner.SwipeAvatar + else -> null + } +} + diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt index e93aef71f5..eb41331202 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt @@ -76,8 +76,6 @@ class RoomListViewTest : RobolectricTest() { val close = activity!!.getString(CommonStrings.action_close) onNodeWithContentDescription(close).performClick() - // The event is emitted at the end of the animation - waitForIdle() eventsRecorder.assertSingle(RoomListEvent.DismissBanner) } @@ -98,8 +96,6 @@ class RoomListViewTest : RobolectricTest() { val close = activity!!.getString(CommonStrings.action_close) onNodeWithContentDescription(close).performClick() - // The event is emitted at the end of the animation - waitForIdle() eventsRecorder.assertSingle(RoomListEvent.DismissBanner) } @@ -121,8 +117,6 @@ class RoomListViewTest : RobolectricTest() { eventsRecorder.assertEmpty() val close = activity!!.getString(CommonStrings.action_close) onNodeWithContentDescription(close).performClick() - // The event is emitted at the end of the animation - waitForIdle() eventsRecorder.assertSingle(RoomListEvent.DismissSwipeAvatarBanner) }