diff --git a/stream-chat-android-compose/api/stream-chat-android-compose.api b/stream-chat-android-compose/api/stream-chat-android-compose.api index 4720ae279f1..57da22cd812 100644 --- a/stream-chat-android-compose/api/stream-chat-android-compose.api +++ b/stream-chat-android-compose/api/stream-chat-android-compose.api @@ -610,7 +610,7 @@ public final class io/getstream/chat/android/compose/ui/attachments/content/Unsu public final class io/getstream/chat/android/compose/ui/attachments/preview/ComposableSingletons$MediaGalleryPreviewScreenKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/attachments/preview/ComposableSingletons$MediaGalleryPreviewScreenKt; - public static field lambda-1 Lkotlin/jvm/functions/Function6; + public static field lambda-1 Lkotlin/jvm/functions/Function7; public static field lambda-10 Lkotlin/jvm/functions/Function2; public static field lambda-11 Lkotlin/jvm/functions/Function2; public static field lambda-12 Lkotlin/jvm/functions/Function2; @@ -621,7 +621,7 @@ public final class io/getstream/chat/android/compose/ui/attachments/preview/Comp public static field lambda-17 Lkotlin/jvm/functions/Function2; public static field lambda-18 Lkotlin/jvm/functions/Function2; public static field lambda-19 Lkotlin/jvm/functions/Function2; - public static field lambda-2 Lkotlin/jvm/functions/Function6; + public static field lambda-2 Lkotlin/jvm/functions/Function7; public static field lambda-20 Lkotlin/jvm/functions/Function2; public static field lambda-21 Lkotlin/jvm/functions/Function2; public static field lambda-22 Lkotlin/jvm/functions/Function2; @@ -638,7 +638,7 @@ public final class io/getstream/chat/android/compose/ui/attachments/preview/Comp public static field lambda-8 Lkotlin/jvm/functions/Function2; public static field lambda-9 Lkotlin/jvm/functions/Function2; public fun ()V - public final fun getLambda-1$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function6; + public final fun getLambda-1$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function7; public final fun getLambda-10$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda-11$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda-12$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; @@ -649,7 +649,7 @@ public final class io/getstream/chat/android/compose/ui/attachments/preview/Comp public final fun getLambda-17$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda-18$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda-19$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda-2$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function6; + public final fun getLambda-2$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function7; public final fun getLambda-20$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda-21$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda-22$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; @@ -711,8 +711,8 @@ public final class io/getstream/chat/android/compose/ui/attachments/preview/Medi } public final class io/getstream/chat/android/compose/ui/attachments/preview/MediaGalleryPreviewScreenKt { - public static final fun MediaGalleryPreviewScreen (Lio/getstream/chat/android/compose/viewmodel/mediapreview/MediaGalleryPreviewViewModel;ILkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;Lio/getstream/chat/android/compose/ui/theme/MediaGalleryConfig;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function6;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function4;Landroidx/compose/runtime/Composer;III)V - public static final fun MediaGalleryPreviewScreen (Lio/getstream/chat/android/models/Message;Lio/getstream/chat/android/models/ConnectionState;Lio/getstream/chat/android/models/User;ILio/getstream/chat/android/models/Attachment;ZZZLkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;Lio/getstream/chat/android/compose/ui/theme/MediaGalleryConfig;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function6;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function4;Landroidx/compose/runtime/Composer;IIII)V + public static final fun MediaGalleryPreviewScreen (Lio/getstream/chat/android/compose/viewmodel/mediapreview/MediaGalleryPreviewViewModel;ILkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;Lio/getstream/chat/android/compose/ui/theme/MediaGalleryConfig;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function7;Lkotlin/jvm/functions/Function5;Lkotlin/jvm/functions/Function4;Landroidx/compose/runtime/Composer;III)V + public static final fun MediaGalleryPreviewScreen (Lio/getstream/chat/android/models/Message;Lio/getstream/chat/android/models/ConnectionState;Lio/getstream/chat/android/models/User;ILio/getstream/chat/android/models/Attachment;ZZZLkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;Lio/getstream/chat/android/compose/ui/theme/MediaGalleryConfig;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function7;Lkotlin/jvm/functions/Function5;Lkotlin/jvm/functions/Function4;Landroidx/compose/runtime/Composer;IIII)V } public final class io/getstream/chat/android/compose/ui/attachments/preview/MediaPreviewActivity : androidx/appcompat/app/AppCompatActivity { @@ -2559,16 +2559,18 @@ public final class io/getstream/chat/android/compose/ui/theme/ChannelListConfig public final class io/getstream/chat/android/compose/ui/theme/ChannelMediaAttachmentsPreviewBottomBarParams { public static final field $stable I - public fun (Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;)V - public synthetic fun (Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;)V + public synthetic fun (Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Lkotlin/jvm/functions/Function2; public final fun component2 ()Lkotlin/jvm/functions/Function2; public final fun component3 ()Lkotlin/jvm/functions/Function2; - public final fun copy (Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;)Lio/getstream/chat/android/compose/ui/theme/ChannelMediaAttachmentsPreviewBottomBarParams; - public static synthetic fun copy$default (Lio/getstream/chat/android/compose/ui/theme/ChannelMediaAttachmentsPreviewBottomBarParams;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lio/getstream/chat/android/compose/ui/theme/ChannelMediaAttachmentsPreviewBottomBarParams; + public final fun component4 ()Lkotlin/jvm/functions/Function2; + public final fun copy (Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;)Lio/getstream/chat/android/compose/ui/theme/ChannelMediaAttachmentsPreviewBottomBarParams; + public static synthetic fun copy$default (Lio/getstream/chat/android/compose/ui/theme/ChannelMediaAttachmentsPreviewBottomBarParams;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lio/getstream/chat/android/compose/ui/theme/ChannelMediaAttachmentsPreviewBottomBarParams; public fun equals (Ljava/lang/Object;)Z public final fun getCenterContent ()Lkotlin/jvm/functions/Function2; public final fun getLeadingContent ()Lkotlin/jvm/functions/Function2; + public final fun getTopContent ()Lkotlin/jvm/functions/Function2; public final fun getTrailingContent ()Lkotlin/jvm/functions/Function2; public fun hashCode ()I public fun toString ()Ljava/lang/String; @@ -2644,7 +2646,6 @@ public abstract interface class io/getstream/chat/android/compose/ui/theme/ChatC public abstract fun ChannelMediaAttachmentsLoadingIndicator (Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;I)V public abstract fun ChannelMediaAttachmentsLoadingItem (Landroidx/compose/foundation/lazy/grid/LazyGridItemScope;Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;I)V public abstract fun ChannelMediaAttachmentsPreviewBottomBar (Lio/getstream/chat/android/compose/ui/theme/ChannelMediaAttachmentsPreviewBottomBarParams;Landroidx/compose/runtime/Composer;I)V - public abstract fun ChannelMediaAttachmentsPreviewBottomBar (Ljava/lang/String;Landroidx/compose/runtime/Composer;I)V public abstract fun ChannelMediaAttachmentsPreviewTopBar (Lio/getstream/chat/android/ui/common/state/channel/attachments/ChannelAttachmentsViewState$Content$Item;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)V public abstract fun ChannelMediaAttachmentsPreviewTopBarTitle (Lio/getstream/chat/android/ui/common/state/channel/attachments/ChannelAttachmentsViewState$Content$Item;Landroidx/compose/runtime/Composer;I)V public abstract fun ChannelMediaAttachmentsTopBar (Landroidx/compose/foundation/lazy/grid/LazyGridState;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)V @@ -2833,7 +2834,6 @@ public final class io/getstream/chat/android/compose/ui/theme/ChatComponentFacto public static fun ChannelMediaAttachmentsLoadingIndicator (Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;I)V public static fun ChannelMediaAttachmentsLoadingItem (Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Landroidx/compose/foundation/lazy/grid/LazyGridItemScope;Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;I)V public static fun ChannelMediaAttachmentsPreviewBottomBar (Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Lio/getstream/chat/android/compose/ui/theme/ChannelMediaAttachmentsPreviewBottomBarParams;Landroidx/compose/runtime/Composer;I)V - public static fun ChannelMediaAttachmentsPreviewBottomBar (Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Ljava/lang/String;Landroidx/compose/runtime/Composer;I)V public static fun ChannelMediaAttachmentsPreviewTopBar (Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Lio/getstream/chat/android/ui/common/state/channel/attachments/ChannelAttachmentsViewState$Content$Item;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)V public static fun ChannelMediaAttachmentsPreviewTopBarTitle (Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Lio/getstream/chat/android/ui/common/state/channel/attachments/ChannelAttachmentsViewState$Content$Item;Landroidx/compose/runtime/Composer;I)V public static fun ChannelMediaAttachmentsTopBar (Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Landroidx/compose/foundation/lazy/grid/LazyGridState;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)V diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/MediaGalleryPreviewOptionsMenu.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/MediaGalleryPreviewOptionsMenu.kt index fad71eeb547..54242143c81 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/MediaGalleryPreviewOptionsMenu.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/MediaGalleryPreviewOptionsMenu.kt @@ -16,22 +16,20 @@ package io.getstream.chat.android.compose.ui.attachments.preview -import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon -import androidx.compose.material3.Surface +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SheetValue import androidx.compose.material3.Text +import androidx.compose.material3.rememberStandardBottomSheetState import androidx.compose.material3.ripple import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -40,7 +38,6 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import io.getstream.chat.android.compose.R import io.getstream.chat.android.compose.state.mediagallerypreview.Delete import io.getstream.chat.android.compose.state.mediagallerypreview.MediaGalleryPreviewOption @@ -50,18 +47,17 @@ import io.getstream.chat.android.compose.state.mediagallerypreview.ShowInChat import io.getstream.chat.android.compose.ui.components.StreamHorizontalDivider import io.getstream.chat.android.compose.ui.theme.ChatTheme import io.getstream.chat.android.compose.ui.theme.MediaGalleryOptionsConfig +import io.getstream.chat.android.compose.ui.theme.StreamTokens import io.getstream.chat.android.models.Attachment import io.getstream.chat.android.models.ConnectionState import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.User /** - * Composable rendering the options menu overlay for media gallery preview. + * Composable rendering the options menu as a bottom sheet for media gallery preview. * - * Displays a dropdown menu in the top-right corner with available actions for the - * currently displayed attachment. The menu appears as a floating surface with a - * semi-transparent overlay covering the entire screen behind it. Clicking anywhere - * outside the menu dismisses it. + * Displays a [ModalBottomSheet] with available actions for the currently displayed attachment. + * The sheet can be dismissed by swiping down, tapping outside, or tapping the scrim. * * Each option is rendered as a [MediaGalleryOptionItem] with dividers between items. * @@ -69,8 +65,9 @@ import io.getstream.chat.android.models.User * @param options List of available options to display in the menu. * @param onOptionClick Callback invoked when an option is clicked, providing both the attachment and option. * @param onDismiss Callback invoked when the menu should be dismissed. - * @param modifier Optional modifier applied to the Surface containing the options. + * @param modifier Optional modifier applied to the [ModalBottomSheet]. */ +@OptIn(ExperimentalMaterial3Api::class) @Composable internal fun MediaGalleryOptionsMenu( attachment: Attachment, @@ -79,39 +76,25 @@ internal fun MediaGalleryOptionsMenu( onDismiss: () -> Unit, modifier: Modifier = Modifier, ) { - Box( - modifier = Modifier - .fillMaxSize() - .background(ChatTheme.colors.backgroundCoreScrim) - .clickable( - indication = null, - interactionSource = null, - onClick = onDismiss, - ), + ModalBottomSheet( + modifier = modifier, + sheetState = rememberStandardBottomSheetState(initialValue = SheetValue.Expanded, skipHiddenState = false), + containerColor = ChatTheme.colors.backgroundElevationElevation1, + scrimColor = ChatTheme.colors.backgroundCoreScrim, + onDismissRequest = onDismiss, ) { - Surface( - modifier = modifier - .padding(16.dp) - .width(150.dp) - .wrapContentHeight() - .align(Alignment.TopEnd), - shape = RoundedCornerShape(16.dp), - shadowElevation = 4.dp, - color = ChatTheme.colors.backgroundElevationElevation1, - ) { - Column(modifier = Modifier.fillMaxWidth()) { - options.forEachIndexed { index, option -> - MediaGalleryOptionItem( - option = option, - onClick = { - onDismiss() - onOptionClick(attachment, option) - }, - ) + Column(modifier = Modifier.fillMaxWidth()) { + options.forEachIndexed { index, option -> + MediaGalleryOptionItem( + option = option, + onClick = { + onOptionClick(attachment, option) + onDismiss() + }, + ) - if (index != options.lastIndex) { - StreamHorizontalDivider() - } + if (index != options.lastIndex) { + StreamHorizontalDivider() } } } @@ -138,28 +121,27 @@ internal fun MediaGalleryOptionItem( Row( modifier = Modifier .fillMaxWidth() - .background(ChatTheme.colors.backgroundElevationElevation1) + .padding(horizontal = StreamTokens.spacing2xs) .clickable( interactionSource = null, indication = ripple(), enabled = option.isEnabled, onClick = onClick, ) - .padding(horizontal = 16.dp, vertical = 8.dp), + .padding(StreamTokens.spacingSm), verticalAlignment = Alignment.CenterVertically, ) { Icon( - modifier = Modifier.size(18.dp), + modifier = Modifier.size(20.dp), painter = option.iconPainter, tint = option.iconColor, contentDescription = option.title, ) - Spacer(modifier = Modifier.width(8.dp)) + Spacer(modifier = Modifier.width(StreamTokens.spacingSm)) Text( text = option.title, color = option.titleColor, - style = ChatTheme.typography.bodyEmphasis, - fontSize = 12.sp, + style = ChatTheme.typography.bodyDefault, maxLines = 1, overflow = TextOverflow.Ellipsis, ) diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/MediaGalleryPreviewScreen.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/MediaGalleryPreviewScreen.kt index 2fc2c9baf29..3dff6d135c8 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/MediaGalleryPreviewScreen.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/MediaGalleryPreviewScreen.kt @@ -21,7 +21,6 @@ package io.getstream.chat.android.compose.ui.attachments.preview import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -30,15 +29,13 @@ import androidx.compose.foundation.layout.Row 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.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHostState @@ -46,9 +43,7 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -57,35 +52,38 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.media3.common.MediaItem import androidx.media3.common.Player import io.getstream.chat.android.client.utils.attachment.isImage import io.getstream.chat.android.client.utils.attachment.isVideo import io.getstream.chat.android.compose.R import io.getstream.chat.android.compose.state.mediagallerypreview.MediaGalleryPreviewOption +import io.getstream.chat.android.compose.ui.attachments.preview.internal.GalleryMediaEffect import io.getstream.chat.android.compose.ui.attachments.preview.internal.MediaGalleryImagePage import io.getstream.chat.android.compose.ui.attachments.preview.internal.MediaGalleryPhotosMenu import io.getstream.chat.android.compose.ui.attachments.preview.internal.MediaGalleryVideoPage -import io.getstream.chat.android.compose.ui.attachments.preview.internal.createPlayer +import io.getstream.chat.android.compose.ui.attachments.preview.internal.VideoPlaybackControls +import io.getstream.chat.android.compose.ui.attachments.preview.internal.rememberMediaGalleryPlayerState +import io.getstream.chat.android.compose.ui.components.LoadingIndicator import io.getstream.chat.android.compose.ui.components.NetworkLoadingIndicator import io.getstream.chat.android.compose.ui.components.SimpleDialog import io.getstream.chat.android.compose.ui.components.Timestamp +import io.getstream.chat.android.compose.ui.components.button.StreamButton +import io.getstream.chat.android.compose.ui.components.button.StreamButtonSize +import io.getstream.chat.android.compose.ui.components.button.StreamButtonStyleDefaults import io.getstream.chat.android.compose.ui.theme.ChatPreviewTheme import io.getstream.chat.android.compose.ui.theme.ChatTheme import io.getstream.chat.android.compose.ui.theme.MediaGalleryConfig +import io.getstream.chat.android.compose.ui.theme.StreamTokens import io.getstream.chat.android.compose.ui.util.StreamSnackbarHost +import io.getstream.chat.android.compose.ui.util.bottomBorder +import io.getstream.chat.android.compose.ui.util.topBorder import io.getstream.chat.android.compose.viewmodel.mediapreview.MediaGalleryPreviewViewModel import io.getstream.chat.android.models.Attachment import io.getstream.chat.android.models.ConnectionState @@ -152,9 +150,7 @@ public fun MediaGalleryPreviewScreen( onDismissGallery: () -> Unit = { viewModel.toggleGallery(false) }, header: @Composable (attachments: List, currentPage: Int) -> Unit = { _, _ -> MediaGalleryPreviewHeader( - modifier = Modifier - .fillMaxWidth() - .height(56.dp), + modifier = Modifier.fillMaxWidth(), message = viewModel.message, connectionState = viewModel.connectionState, onLeadingContentClick = onHeaderLeadingContentClick, @@ -165,29 +161,37 @@ public fun MediaGalleryPreviewScreen( padding: PaddingValues, pagerState: PagerState, attachments: List, - onPlaybackError: () -> Unit, - ) -> Unit = { padding, pagerState, attachments, onPlaybackError -> + player: Player?, + onMediaClick: () -> Unit, + ) -> Unit = { padding, pagerState, attachments, player, onMediaClick -> MediaGalleryPager( modifier = Modifier .fillMaxSize() - .background(ChatTheme.colors.backgroundCoreApp) .padding(padding), + player = player, pagerState = pagerState, attachments = attachments, - onPlaybackError = { onPlaybackError() }, - ) - }, - footer: @Composable (attachments: List, currentPage: Int) -> Unit = { attachments, currentPage -> - MediaGalleryPreviewFooter( - attachments = attachments, - currentPage = currentPage, - totalPages = attachments.size, - connectionState = viewModel.connectionState, - isSharingInProgress = viewModel.isSharingInProgress, - onLeadingContentClick = onFooterLeadingContentClick, - onTrailingContentClick = onFooterTrailingContentClick, + onMediaClick = onMediaClick, ) }, + footer: @Composable (attachments: List, currentPage: Int, player: Player?) -> Unit = + { attachments, currentPage, player -> + MediaGalleryPreviewFooter( + attachments = attachments, + currentPage = currentPage, + totalPages = attachments.size, + connectionState = viewModel.connectionState, + isSharingInProgress = viewModel.isSharingInProgress, + onLeadingContentClick = onFooterLeadingContentClick, + onTrailingContentClick = onFooterTrailingContentClick, + topContent = { + val currentAttachment = attachments.getOrNull(currentPage) + if (player != null && currentAttachment?.isVideo() == true) { + VideoPlaybackControls(player = player) + } + }, + ) + }, optionsMenu: @Composable ( attachment: Attachment, options: List, @@ -293,9 +297,7 @@ public fun MediaGalleryPreviewScreen( onDismissGallery: () -> Unit = {}, header: @Composable (attachments: List, currentPage: Int) -> Unit = { _, _ -> MediaGalleryPreviewHeader( - modifier = Modifier - .fillMaxWidth() - .height(56.dp), + modifier = Modifier.fillMaxWidth(), message = message, connectionState = connectionState, onLeadingContentClick = onHeaderLeadingContentClick, @@ -306,29 +308,37 @@ public fun MediaGalleryPreviewScreen( padding: PaddingValues, pagerState: PagerState, attachments: List, - onPlaybackError: () -> Unit, - ) -> Unit = { padding, pagerState, attachments, onPlaybackError -> + player: Player?, + onMediaClick: () -> Unit, + ) -> Unit = { padding, pagerState, attachments, player, onMediaClick -> MediaGalleryPager( modifier = Modifier .fillMaxSize() - .background(ChatTheme.colors.backgroundCoreApp) .padding(padding), + player = player, pagerState = pagerState, attachments = attachments, - onPlaybackError = { onPlaybackError() }, - ) - }, - footer: @Composable (attachments: List, currentPage: Int) -> Unit = { attachments, currentPage -> - MediaGalleryPreviewFooter( - attachments = attachments, - currentPage = currentPage, - totalPages = attachments.size, - connectionState = connectionState, - isSharingInProgress = isSharingInProgress, - onLeadingContentClick = onFooterLeadingContentClick, - onTrailingContentClick = onFooterTrailingContentClick, + onMediaClick = onMediaClick, ) }, + footer: @Composable (attachments: List, currentPage: Int, player: Player?) -> Unit = + { attachments, currentPage, player -> + MediaGalleryPreviewFooter( + attachments = attachments, + currentPage = currentPage, + totalPages = attachments.size, + connectionState = connectionState, + isSharingInProgress = isSharingInProgress, + onLeadingContentClick = onFooterLeadingContentClick, + onTrailingContentClick = onFooterTrailingContentClick, + topContent = { + val currentAttachment = attachments.getOrNull(currentPage) + if (player != null && currentAttachment?.isVideo() == true) { + VideoPlaybackControls(player = player) + } + }, + ) + }, optionsMenu: @Composable ( attachment: Attachment, options: List, @@ -341,11 +351,11 @@ public fun MediaGalleryPreviewScreen( ) }, ) { - // Filters out any link attachments. Pass this value along to all children + // Filters out non-media and link attachments. Pass this value along to all children // Composable-s that read message attachments to prevent inconsistent state. - val filteredAttachments by remember(message) { - derivedStateOf { - message.attachments.filter { attachment -> !attachment.hasLink() } + val filteredAttachments = remember(message) { + message.attachments.filter { attachment -> + !attachment.hasLink() && (attachment.isImage() || attachment.isVideo()) } } val startingPosition = if (initialPage !in filteredAttachments.indices) 0 else initialPage @@ -360,41 +370,64 @@ public fun MediaGalleryPreviewScreen( } } + val playbackErrorText = stringResource(R.string.stream_ui_message_list_video_display_error) val coroutineScope = rememberCoroutineScope() val snackbarHostState = remember { SnackbarHostState() } + var isImmersive by remember { mutableStateOf(false) } + + // Hoisted player state shared between the pager content and the bottom bar. + val playerState = rememberMediaGalleryPlayerState( + onPlaybackError = { + coroutineScope.launch { + snackbarHostState.showSnackbar( + message = playbackErrorText, + duration = SnackbarDuration.Short, + ) + } + }, + ) + GalleryMediaEffect(playerState, pagerState.currentPage, filteredAttachments) // Full-size container holding the main scaffold and the overlay menus Box(modifier = modifier) { + // Scaffold padding is intentionally ignored to prevent content shifting when toggling immersive mode + @Suppress("UnusedMaterial3ScaffoldPaddingParameter") Scaffold( modifier = Modifier.fillMaxSize(), + containerColor = ChatTheme.colors.backgroundCoreApp, topBar = { - header(filteredAttachments, pagerState.currentPage) + AnimatedVisibility( + visible = !isImmersive, + enter = fadeIn(), + exit = fadeOut(), + ) { + header(filteredAttachments, pagerState.currentPage) + } }, bottomBar = { - if (message.id.isNotEmpty()) { - footer(filteredAttachments, pagerState.currentPage) + AnimatedVisibility( + visible = !isImmersive && message.id.isNotEmpty(), + enter = fadeIn(), + exit = fadeOut(), + ) { + footer(filteredAttachments, pagerState.currentPage, playerState.player) } }, - ) { padding -> + ) { _ -> if (message.id.isNotEmpty()) { Box(modifier = Modifier.fillMaxSize()) { // Main content - val playbackErrorText = stringResource(R.string.stream_ui_message_list_video_display_error) - content(padding, pagerState, filteredAttachments) { - // Show snackbar when playback error occurs - coroutineScope.launch { - snackbarHostState.showSnackbar( - message = playbackErrorText, - duration = SnackbarDuration.Short, - ) - } - } + content( + PaddingValues(), + pagerState, + filteredAttachments, + playerState.player, + { isImmersive = !isImmersive }, + ) // Error snackbar StreamSnackbarHost( hostState = snackbarHostState, - modifier = Modifier - .align(Alignment.BottomCenter) - .padding(bottom = padding.calculateBottomPadding()), + modifier = Modifier.align(Alignment.BottomCenter), ) } // Prompt the user to share a large file (if needed) @@ -412,16 +445,10 @@ public fun MediaGalleryPreviewScreen( } // Attachment options - AnimatedVisibility( - visible = isShowingOptions, - enter = fadeIn(), - exit = fadeOut(), - ) { - if (pagerState.currentPage in filteredAttachments.indices) { - val attachment = filteredAttachments[pagerState.currentPage] - val options = defaultMediaOptions(currentUser, message, connectionState, config.optionsConfig) - optionsMenu(attachment, options) - } + if (isShowingOptions && pagerState.currentPage in filteredAttachments.indices) { + val attachment = filteredAttachments[pagerState.currentPage] + val options = defaultMediaOptions(currentUser, message, connectionState, config.optionsConfig) + optionsMenu(attachment, options) } // Gallery @@ -458,7 +485,6 @@ public fun MediaGalleryPreviewScreen( * @param onLeadingContentClick Callback to be invoked when the leading content is clicked. * @param onTrailingContentClick Callback to be invoked when the trailing content is clicked. * @param modifier The [Modifier] to be applied to the header. - * @param elevation The elevation of the header. * @param backgroundColor The background color of the header. * @param contentColor The content color of the header. * @param config The configuration for the media gallery. @@ -476,7 +502,6 @@ internal fun MediaGalleryPreviewHeader( onLeadingContentClick: () -> Unit, onTrailingContentClick: () -> Unit, modifier: Modifier = Modifier, - elevation: Dp = 4.dp, backgroundColor: Color = ChatTheme.colors.backgroundElevationElevation1, contentColor: Color = ChatTheme.colors.textPrimary, config: MediaGalleryConfig = ChatTheme.config.mediaGallery, @@ -510,17 +535,18 @@ internal fun MediaGalleryPreviewHeader( }, ) { Surface( - modifier = modifier, - shadowElevation = elevation, + modifier = modifier + .wrapContentHeight() + .bottomBorder(ChatTheme.colors.borderCoreSubtle), color = backgroundColor, contentColor = contentColor, ) { Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 8.dp), + .padding(StreamTokens.spacingSm), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, + horizontalArrangement = Arrangement.spacedBy(StreamTokens.spacingXs), ) { leadingContent(Modifier) centerContent(Modifier.weight(1f)) @@ -536,79 +562,18 @@ internal fun MediaGalleryPreviewHeader( * @param pagerState The [PagerState] for managing the pager's state. (passed from outside, so it can be also used by * the adjacent components. For example, to show the current position of the pager in the footer) * @param attachments The list of [Attachment]s to be displayed in the pager. - * @param onPlaybackError Callback to be invoked when an error during the playing of a video occurs. * @param modifier The [Modifier] to be applied to the pager. + * @param player The [Player] instance used for video playback. + * @param onMediaClick Callback to be invoked when the media is clicked (e.g., to toggle immersive mode). */ -@Suppress("LongMethod") @Composable internal fun MediaGalleryPager( pagerState: PagerState, attachments: List, - onPlaybackError: (error: Throwable) -> Unit, + player: Player?, modifier: Modifier = Modifier, + onMediaClick: () -> Unit = {}, ) { - val context = LocalContext.current - val previewMode = LocalInspectionMode.current - var showBuffering by remember { mutableStateOf(true) } - // Create a single instance of the player for the pager, - // so it can be reused across pages, - // improving performance and preventing issues when switching between pages. - var player by remember { mutableStateOf(null) } - // Saved playback state (pageIndex to position) for restoration after app resume. - var savedPlaybackState by remember { mutableStateOf?>(null) } - val currentPage = pagerState.currentPage - LaunchedEffect(currentPage, player) { - player?.let { activePlayer -> - activePlayer.pause() // Pause the player when the page changes - val attachment = attachments[currentPage] - // Prepare the player with the media item if it's a video. - if (attachment.isVideo()) { - attachment.assetUrl?.let { assetUrl -> - // Restore playback position if returning to the same page after app resume. - val startPosition = savedPlaybackState - ?.takeIf { (savedPage, _) -> savedPage == currentPage } - ?.second - ?: 0L - savedPlaybackState = null - activePlayer.setMediaItem(MediaItem.fromUri(assetUrl), startPosition) - activePlayer.prepare() - } - } - } - } - val lifecycleOwner = LocalLifecycleOwner.current - DisposableEffect(lifecycleOwner, previewMode) { - val observer = LifecycleEventObserver { _, event -> - when (event) { - Lifecycle.Event.ON_START -> { - // Player should not be created in preview mode to prevent exceptions. - if (!previewMode && player == null) { - player = createPlayer( - context = context, - onBuffering = { isBuffering -> showBuffering = isBuffering }, - onPlaybackError = onPlaybackError, - ) - } - } - Lifecycle.Event.ON_PAUSE -> { - player?.pause() - } - Lifecycle.Event.ON_STOP -> { - // Save playback position before releasing the player. - savedPlaybackState = pagerState.currentPage to (player?.currentPosition ?: 0L) - player?.release() - player = null - } - else -> Unit - } - } - lifecycleOwner.lifecycle.addObserver(observer) - onDispose { - lifecycleOwner.lifecycle.removeObserver(observer) - player?.release() - player = null - } - } HorizontalPager( modifier = modifier, state = pagerState, @@ -620,6 +585,7 @@ internal fun MediaGalleryPager( attachment = attachment, pagerState = pagerState, page = page, + onTap = onMediaClick, ) } @@ -629,8 +595,7 @@ internal fun MediaGalleryPager( modifier = Modifier.fillMaxSize(), player = it, thumbnailUrl = attachment.thumbUrl, - showBuffering = showBuffering, - onPlaybackError = onPlaybackError, + onClick = onMediaClick, ) } } @@ -676,10 +641,10 @@ internal fun MediaGalleryPreviewFooter( onLeadingContentClick: (Attachment) -> Unit, onTrailingContentClick: (Attachment) -> Unit, modifier: Modifier = Modifier, - elevation: Dp = 4.dp, backgroundColor: Color = ChatTheme.colors.backgroundElevationElevation1, contentColor: Color = ChatTheme.colors.textPrimary, config: MediaGalleryConfig = ChatTheme.config.mediaGallery, + topContent: @Composable (() -> Unit)? = null, leadingContent: @Composable (Modifier) -> Unit = { if (config.isShareVisible) { MediaGalleryPreviewShareIcon( @@ -692,11 +657,12 @@ internal fun MediaGalleryPreviewFooter( Spacer(modifier = Modifier.minimumInteractiveComponentSize()) } }, - centerContent: @Composable (Modifier) -> Unit = { + centerContent: @Composable (Modifier) -> Unit = { modifier -> if (isSharingInProgress) { - MediaGalleryPreviewSharingInProgressIndicator() + MediaGalleryPreviewSharingInProgressIndicator(modifier = modifier) } else { MediaGalleryPreviewPageIndicator( + modifier = modifier, currentPage = currentPage, totalPages = totalPages, ) @@ -714,21 +680,25 @@ internal fun MediaGalleryPreviewFooter( }, ) { Surface( - modifier = modifier, - shadowElevation = elevation, + modifier = modifier + .fillMaxWidth() + .topBorder(ChatTheme.colors.borderCoreDefault), color = backgroundColor, contentColor = contentColor, ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - leadingContent(Modifier) - centerContent(Modifier.weight(1f)) - trailingContent(Modifier) + Column { + topContent?.invoke() + Row( + modifier = Modifier + .fillMaxWidth() + .padding(StreamTokens.spacingSm), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(StreamTokens.spacingSm), + ) { + leadingContent(Modifier) + centerContent(Modifier.weight(1f)) + trailingContent(Modifier) + } } } } @@ -744,13 +714,15 @@ internal fun MediaGalleryPreviewCloseIcon( onClick: () -> Unit, modifier: Modifier = Modifier, ) { - IconButton( - modifier = modifier, + StreamButton( + modifier = modifier.minimumInteractiveComponentSize(), onClick = onClick, + style = StreamButtonStyleDefaults.secondaryGhost, + size = StreamButtonSize.Medium, ) { Icon( - painter = painterResource(id = R.drawable.stream_compose_ic_close), - contentDescription = stringResource(id = R.string.stream_compose_cancel), + painter = painterResource(id = R.drawable.stream_compose_ic_arrow_back), + contentDescription = stringResource(id = R.string.stream_ui_back_button), ) } } @@ -778,11 +750,11 @@ internal fun MediaGalleryPreviewTitle( modifier: Modifier = Modifier, ) { Column( - modifier = modifier, + modifier = modifier.padding(vertical = StreamTokens.spacing2xs), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { - val textStyle = ChatTheme.typography.headingMedium + val textStyle = ChatTheme.typography.headingSmall when (connectionState) { is ConnectionState.Connected -> Text( @@ -806,7 +778,12 @@ internal fun MediaGalleryPreviewTitle( val timestamp = message.updatedAt ?: message.createdAt if (timestamp != null) { - Timestamp(date = timestamp) + Timestamp( + date = timestamp, + textStyle = ChatTheme.typography.captionDefault.copy( + color = ChatTheme.colors.textSecondary, + ), + ) } } } @@ -826,10 +803,12 @@ internal fun MediaGalleryPreviewOptionsIcon( modifier: Modifier = Modifier, ) { val enabled = message.id.isNotEmpty() - IconButton( - modifier = modifier, + StreamButton( + modifier = modifier.minimumInteractiveComponentSize(), enabled = enabled, onClick = onClick, + style = StreamButtonStyleDefaults.secondaryGhost, + size = StreamButtonSize.Medium, ) { Icon( painter = painterResource(id = R.drawable.stream_compose_ic_menu_vertical), @@ -861,10 +840,12 @@ internal fun MediaGalleryPreviewShareIcon( modifier: Modifier = Modifier, ) { val enabled = connectionState is ConnectionState.Connected - IconButton( - modifier = modifier, + StreamButton( + modifier = modifier.minimumInteractiveComponentSize(), enabled = enabled, onClick = onClick, + style = StreamButtonStyleDefaults.secondaryGhost, + size = StreamButtonSize.Medium, ) { val painter = if (isSharingInProgress) { R.drawable.stream_compose_ic_clear @@ -879,6 +860,7 @@ internal fun MediaGalleryPreviewShareIcon( Icon( painter = painterResource(id = painter), contentDescription = stringResource(id = description), + modifier = Modifier.size(24.dp), ) } } @@ -896,12 +878,15 @@ internal fun MediaGalleryPreviewShareIcon( internal fun MediaGalleryPreviewPageIndicator( currentPage: Int, totalPages: Int, + modifier: Modifier = Modifier, ) { val text = stringResource(id = R.string.stream_compose_image_order, currentPage + 1, totalPages) Text( + modifier = modifier, text = text, - style = ChatTheme.typography.headingMedium, + style = ChatTheme.typography.captionEmphasis, color = ChatTheme.colors.textPrimary, + textAlign = TextAlign.Center, maxLines = 1, ) } @@ -913,18 +898,20 @@ internal fun MediaGalleryPreviewPageIndicator( * "Preparing..." text, indicating that a media sharing operation is in progress. */ @Composable -internal fun MediaGalleryPreviewSharingInProgressIndicator() { - Row { - CircularProgressIndicator( +internal fun MediaGalleryPreviewSharingInProgressIndicator(modifier: Modifier = Modifier) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + LoadingIndicator( modifier = Modifier .padding(horizontal = 12.dp) .size(24.dp), - strokeWidth = 2.dp, - color = ChatTheme.colors.accentPrimary, ) Text( text = stringResource(id = R.string.stream_compose_media_gallery_preview_preparing), - style = ChatTheme.typography.headingMedium, + style = ChatTheme.typography.captionEmphasis, ) } } @@ -940,9 +927,11 @@ internal fun MediaGalleryPreviewPhotosIcon( onClick: () -> Unit, modifier: Modifier = Modifier, ) { - IconButton( - modifier = modifier, + StreamButton( + modifier = modifier.minimumInteractiveComponentSize(), onClick = onClick, + style = StreamButtonStyleDefaults.secondaryGhost, + size = StreamButtonSize.Medium, ) { Icon( painter = painterResource(id = R.drawable.stream_compose_ic_gallery), diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/internal/MediaGalleryPage.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/internal/MediaGalleryPage.kt index 8e2bafe659c..50b4d37a890 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/internal/MediaGalleryPage.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/internal/MediaGalleryPage.kt @@ -17,12 +17,12 @@ package io.getstream.chat.android.compose.ui.attachments.preview.internal import android.annotation.SuppressLint -import android.util.Log import androidx.annotation.OptIn import androidx.compose.animation.Crossfade import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.calculatePan @@ -35,6 +35,8 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.pager.PagerState import androidx.compose.material3.Icon import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf @@ -44,7 +46,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.pointerInput @@ -97,6 +98,7 @@ internal fun MediaGalleryImagePage( attachment: Attachment, pagerState: PagerState, page: Int, + onTap: () -> Unit = {}, ) { @SuppressLint("UnusedBoxWithConstraintsScope") BoxWithConstraints( @@ -121,6 +123,7 @@ internal fun MediaGalleryImagePage( var currentScale by remember { mutableFloatStateOf(DefaultZoomScale) } var translation by remember { mutableStateOf(Offset(0f, 0f)) } + var wasDragged by remember { mutableStateOf(false) } val scale by animateFloatAsState(targetValue = currentScale, label = "") @@ -157,6 +160,7 @@ internal fun MediaGalleryImagePage( coroutineScope { awaitEachGesture { awaitFirstDown(requireUnconsumed = true) + wasDragged = false do { val event = awaitPointerEvent(pass = PointerEventPass.Initial) @@ -170,6 +174,9 @@ internal fun MediaGalleryImagePage( ) val offset = event.calculatePan() + if (offset != Offset.Zero || zoom != DefaultZoomScale) { + wasDragged = true + } val newTranslationX = translation.x + offset.x * currentScale val newTranslationY = translation.y + offset.y * currentScale @@ -198,8 +205,10 @@ internal fun MediaGalleryImagePage( coroutineScope { awaitEachGesture { awaitFirstDown() - withTimeoutOrNull(DoubleTapTimeoutMs) { + val secondDown = withTimeoutOrNull(DoubleTapTimeoutMs) { awaitFirstDown() + } + if (secondDown != null) { currentScale = when { currentScale == MaxZoomScale -> DefaultZoomScale currentScale >= MidZoomScale -> MaxZoomScale @@ -209,6 +218,8 @@ internal fun MediaGalleryImagePage( if (currentScale == DefaultZoomScale) { translation = Offset(0f, 0f) } + } else if (!wasDragged) { + onTap() } } } @@ -235,11 +246,11 @@ internal fun MediaGalleryImagePage( } } - Log.d("isCurrentPage", "${page != pagerState.currentPage}") - - if (pagerState.currentPage != page) { - currentScale = DefaultZoomScale - translation = Offset(0f, 0f) + LaunchedEffect(pagerState.settledPage) { + if (pagerState.settledPage != page) { + currentScale = DefaultZoomScale + translation = Offset(0f, 0f) + } } } } @@ -285,8 +296,6 @@ private fun ErrorIcon(modifier: Modifier) { * * @param player The [Player] instance used for video playback. * @param thumbnailUrl The url of the thumbnail to display before the video is played. - * @param showBuffering Whether to show a buffering indicator while the video is loading. - * @param onPlaybackError Callback invoked when video playback encounters an error. * @param modifier The [Modifier] to be applied to the video player. */ @OptIn(UnstableApi::class) @@ -294,22 +303,39 @@ private fun ErrorIcon(modifier: Modifier) { internal fun MediaGalleryVideoPage( player: Player, thumbnailUrl: String?, - showBuffering: Boolean, - onPlaybackError: (error: Throwable) -> Unit, modifier: Modifier = Modifier, + onClick: () -> Unit = {}, ) { var showThumbnail by remember { mutableStateOf(true) } + var showBuffering by remember { mutableStateOf(player.playbackState == Player.STATE_BUFFERING) } + DisposableEffect(player) { + val listener = object : Player.Listener { + override fun onIsPlayingChanged(isPlaying: Boolean) { + if (isPlaying) showThumbnail = false + } + + override fun onPlaybackStateChanged(playbackState: Int) { + showBuffering = playbackState == Player.STATE_BUFFERING + } + } + player.addListener(listener) + onDispose { player.removeListener(listener) } + } Box( - modifier = modifier, + modifier = modifier.clickable( + interactionSource = null, + indication = null, + onClick = onClick, + ), contentAlignment = Alignment.Center, ) { // Video player AndroidView( modifier = Modifier .matchParentSize() - .background(Color.Black), + .background(ChatTheme.colors.backgroundCoreApp), factory = { context -> - createPlayerView(context, player) + createPlayerView(context, player, useController = false) }, ) // Thumbnail diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/internal/MediaGalleryPlayerState.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/internal/MediaGalleryPlayerState.kt new file mode 100644 index 00000000000..eb051bd2a63 --- /dev/null +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/internal/MediaGalleryPlayerState.kt @@ -0,0 +1,134 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.compose.ui.attachments.preview.internal + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import io.getstream.chat.android.client.utils.attachment.isVideo +import io.getstream.chat.android.models.Attachment + +@Stable +internal class MediaGalleryPlayerState internal constructor() { + var player: Player? by mutableStateOf(null) + internal set + + /** Playback position captured before the last player release. */ + var savedPosition: Long = 0L + internal set +} + +/** + * Creates and remembers a lifecycle-managed [Player]. + * + * @param onPlaybackError Callback invoked when a playback error occurs. + */ +@Composable +internal fun rememberMediaGalleryPlayerState( + onPlaybackError: (Throwable) -> Unit, +): MediaGalleryPlayerState { + val context = LocalContext.current + val previewMode = LocalInspectionMode.current + val lifecycleOwner = LocalLifecycleOwner.current + val state = remember(::MediaGalleryPlayerState) + + DisposableEffect(lifecycleOwner, previewMode) { + val observer = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_START -> { + if (!previewMode && state.player == null) { + state.player = createPlayer( + context = context, + onPlaybackError = onPlaybackError, + onBuffering = {}, + ) + } + } + + Lifecycle.Event.ON_PAUSE -> state.player?.pause() + + Lifecycle.Event.ON_STOP -> { + state.savedPosition = state.player?.currentPosition ?: 0L + state.player?.release() + state.player = null + } + + else -> Unit + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + state.player?.release() + state.player = null + } + } + + return state +} + +/** + * A side effect that prepares the correct media item whenever the gallery page changes + * or the player becomes available. + * + * When the player is recreated on the same page (e.g. after ON_STOP → ON_START), + * playback resumes from [MediaGalleryPlayerState.savedPosition]. + * + * @param playerState The lifecycle-managed player state. + * @param currentPage The current pager page index. + * @param attachments The list of attachments displayed in the pager. + */ +@Composable +internal fun GalleryMediaEffect( + playerState: MediaGalleryPlayerState, + currentPage: Int, + attachments: List, +) { + var lastPreparedPage by remember { mutableIntStateOf(-1) } + + LaunchedEffect(currentPage, playerState.player, attachments) { + playerState.player?.let { player -> + player.pause() + val attachment = attachments.getOrNull(currentPage) ?: return@LaunchedEffect + if (attachment.isVideo()) { + attachment.assetUrl?.let { assetUrl -> + val startPosition = if (currentPage == lastPreparedPage) { + playerState.savedPosition + } else { + 0L + } + player.setMediaItem(MediaItem.fromUri(assetUrl), startPosition) + player.prepare() + } + } + lastPreparedPage = currentPage + } + } +} diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/internal/StreamMediaPlayerContent.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/internal/StreamMediaPlayerContent.kt index cdf58302cca..f5a96a060c6 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/internal/StreamMediaPlayerContent.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/internal/StreamMediaPlayerContent.kt @@ -44,6 +44,7 @@ import io.getstream.chat.android.compose.R import io.getstream.chat.android.compose.ui.components.LoadingIndicator import io.getstream.chat.android.compose.ui.components.common.PlayButton import io.getstream.chat.android.compose.ui.components.common.PlayButtonSize +import io.getstream.chat.android.compose.ui.theme.ChatTheme import io.getstream.chat.android.compose.ui.util.StreamAsyncImage import io.getstream.chat.android.compose.ui.util.clickable import io.getstream.chat.android.core.internal.StreamHandsOff @@ -154,7 +155,7 @@ internal fun MediaThumbnail( modifier: Modifier = Modifier, ) { Box( - modifier = modifier.background(Color.Black), + modifier = modifier.background(ChatTheme.colors.backgroundCoreApp), contentAlignment = Alignment.Center, ) { if (thumbnailUrl != null) { @@ -219,11 +220,16 @@ internal fun createPlayer( "we always use the correct layout for our version of the ExoPlayer library", ) @OptIn(UnstableApi::class) -internal fun createPlayerView(context: Context, player: Player): PlayerView { +internal fun createPlayerView( + context: Context, + player: Player, + useController: Boolean = true, +): PlayerView { val playerView = LayoutInflater.from(context) .inflate(R.layout.stream_compose_player_view, null) as PlayerView return playerView.apply { this.player = player + this.useController = useController controllerShowTimeoutMs = ControllerShowTimeout controllerAutoShow = false controllerHideOnTouch = true diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/internal/VideoPlaybackControls.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/internal/VideoPlaybackControls.kt new file mode 100644 index 00000000000..c8ab66b5a68 --- /dev/null +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/internal/VideoPlaybackControls.kt @@ -0,0 +1,368 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.compose.ui.attachments.preview.internal + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.snap +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.progressSemantics +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.material3.minimumInteractiveComponentSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.coerceIn +import androidx.compose.ui.unit.dp +import androidx.media3.common.MediaItem +import androidx.media3.common.PlaybackParameters +import androidx.media3.common.Player +import io.getstream.chat.android.compose.R +import io.getstream.chat.android.compose.ui.components.button.StreamButton +import io.getstream.chat.android.compose.ui.components.button.StreamButtonSize +import io.getstream.chat.android.compose.ui.components.button.StreamButtonStyleDefaults +import io.getstream.chat.android.compose.ui.components.common.PlaybackSpeedToggle +import io.getstream.chat.android.compose.ui.theme.ChatTheme +import io.getstream.chat.android.compose.ui.theme.StreamTokens +import io.getstream.chat.android.compose.ui.util.dragPointerInput +import kotlinx.coroutines.delay + +/** + * A composable that displays video playback controls for the media gallery. + * + * Contains play/pause button, current time, seek bar, and speed toggle. + * + * @param player The [Player] instance to control. + * @param modifier The [Modifier] to be applied. + */ +@Composable +internal fun VideoPlaybackControls( + player: Player, + modifier: Modifier = Modifier, +) { + val state = rememberVideoPlaybackControlsState(player) + + Row( + modifier = modifier.padding(start = StreamTokens.spacingSm, end = StreamTokens.spacingMd), + verticalAlignment = Alignment.CenterVertically, + ) { + // Play/Pause button + StreamButton( + onClick = state::togglePlayPause, + style = StreamButtonStyleDefaults.secondaryGhost, + size = StreamButtonSize.Small, + modifier = Modifier.minimumInteractiveComponentSize(), + ) { + val icon = if (state.isPlaying) { + R.drawable.stream_compose_ic_pause + } else { + R.drawable.stream_compose_ic_play + } + val contentDescription = if (state.isPlaying) { + R.string.stream_compose_audio_playback_pause + } else { + R.string.stream_compose_cd_play_button + } + Icon( + painter = painterResource(icon), + contentDescription = stringResource(contentDescription), + modifier = Modifier.size(20.dp), + ) + } + + Text( + text = ChatTheme.durationFormatter.format(state.currentPosition.toInt()), + style = ChatTheme.typography.captionDefault, + color = if (state.isPlaying) ChatTheme.colors.accentPrimary else ChatTheme.colors.textPrimary, + ) + + PlaybackSlider( + progress = state.progress, + isPlaying = state.isPlaying, + modifier = Modifier + .weight(1f) + .height(20.dp) + .padding(horizontal = StreamTokens.spacingMd), + onDragStart = { state.onDragStart() }, + onDrag = state::onDrag, + onDragStop = state::onDragStop, + ) + + PlaybackSpeedToggle( + speed = state.speed, + onClick = state::cycleSpeed, + ) + } +} + +/** + * A progress bar matching the Figma "Mobile / Playback Progress Bar" component. + * + * Displays a rounded track (4dp) with a 12dp white circular thumb with border and shadow. + * + * @param progress The current progress (0f..1f). + * @param isPlaying Whether playback is active (changes thumb and track colors). + * @param modifier The [Modifier] to be applied. + * @param onDragStart Callback when the user starts dragging. + * @param onDrag Callback during drag with the current progress. + * @param onDragStop Callback when the user stops dragging with the final progress. + */ +@Composable +private fun PlaybackSlider( + progress: Float, + isPlaying: Boolean, + modifier: Modifier = Modifier, + onDragStart: (Float) -> Unit = {}, + onDrag: (Float) -> Unit = {}, + onDragStop: (Float) -> Unit = {}, +) { + val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl + val currentProgress by rememberUpdatedState(progress) + var widthPx by remember { mutableFloatStateOf(0f) } + val animatedProgress by animateFloatAsState( + targetValue = progress, + animationSpec = if (isPlaying) { + tween(durationMillis = PositionPollingIntervalMs.toInt(), easing = LinearEasing) + } else { + snap() + }, + label = "playback-progress", + ) + Box( + modifier = modifier + .progressSemantics(value = progress) + .onSizeChanged { size -> widthPx = size.width.toFloat() } + .dragPointerInput( + enabled = true, + onDragStart = { onDragStart(it.toHorizontalProgress(widthPx, isRtl)) }, + onDrag = { onDrag(it.toHorizontalProgress(widthPx, isRtl)) }, + onDragStop = { onDragStop(it?.toHorizontalProgress(widthPx, isRtl) ?: currentProgress) }, + ), + contentAlignment = Alignment.CenterStart, + ) { + // Track background + Box( + modifier = Modifier + .fillMaxWidth() + .height(TrackHeight) + .clip(CircleShape) + .background(ChatTheme.colors.chatWaveformBar), + ) + // Active track + Box( + modifier = Modifier + .fillMaxWidth(fraction = animatedProgress) + .height(TrackHeight) + .clip(CircleShape) + .background(ChatTheme.colors.chatWaveformBarPlaying), + ) + // Thumb + PlaybackThumb(progress = animatedProgress, isPlaying = isPlaying, parentWidthPx = widthPx) + } +} + +@Composable +private fun BoxScope.PlaybackThumb( + progress: Float, + isPlaying: Boolean, + parentWidthPx: Float, +) { + val thumbOffset = if (parentWidthPx > 0) { + with(LocalDensity.current) { + val parentWidth = parentWidthPx.toDp() + val center = parentWidth * progress + val left = center - (ThumbSize / 2) + left.coerceIn(0.dp, parentWidth - ThumbSize) + } + } else { + 0.dp + } + val colors = ChatTheme.colors + val bgColor = if (isPlaying) { + colors.controlPlaybackThumbBgActive + } else { + colors.controlPlaybackThumbBgDefault + } + val borderColor = if (isPlaying) { + colors.controlPlaybackThumbBorderActive + } else { + colors.controlPlaybackThumbBorderDefault + } + Box( + modifier = Modifier + .align(Alignment.CenterStart) + .offset(x = thumbOffset) + .size(ThumbSize) + .shadow(2.dp, CircleShape) + .background(bgColor, CircleShape) + .border(1.dp, borderColor, CircleShape), + ) +} + +private fun Offset.toHorizontalProgress(widthPx: Float, isRtl: Boolean): Float { + val raw = (x / widthPx).coerceIn(0f, 1f) + return if (isRtl) 1f - raw else raw +} + +@Suppress("MagicNumber") +private val PlaybackSpeeds = floatArrayOf(1f, 1.5f, 2f) +private const val PositionPollingIntervalMs = 100L +private val TrackHeight = 4.dp +private val ThumbSize = 12.dp + +/** + * Observable state holder for [VideoPlaybackControls]. + * + * Observes a [Player] via listener for discrete events (play/pause, speed, duration) + * and polls for continuous position updates while playing. + * + * @param player The [Player] instance to observe and control. + */ +@Stable +internal class VideoPlaybackControlsState(private val player: Player) { + var isPlaying: Boolean by mutableStateOf(player.isPlaying) + private set + + var currentPosition: Long by mutableLongStateOf(player.currentPosition) + private set + + var duration: Long by mutableLongStateOf(player.duration.coerceAtLeast(0L)) + private set + + var speed: Float by mutableFloatStateOf(player.playbackParameters.speed) + private set + + var isSeeking: Boolean by mutableStateOf(false) + private set + + val progress: Float + get() = when { + duration > 0 -> (currentPosition.toFloat() / duration.toFloat()).coerceIn(0f, 1f) + else -> 0f + } + + val listener: Player.Listener = object : Player.Listener { + override fun onIsPlayingChanged(playing: Boolean) { + isPlaying = playing + } + + override fun onPlaybackParametersChanged(playbackParameters: PlaybackParameters) { + speed = playbackParameters.speed + } + + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { + duration = player.duration.coerceAtLeast(0L) + currentPosition = player.currentPosition.coerceAtLeast(0L) + } + + override fun onPlaybackStateChanged(playbackState: Int) { + if (playbackState == Player.STATE_READY) { + duration = player.duration.coerceAtLeast(0L) + } + if (playbackState == Player.STATE_ENDED) { + currentPosition = duration + } + } + } + + fun togglePlayPause() { + if (player.isPlaying) { + player.pause() + } else { + if (player.playbackState == Player.STATE_ENDED) { + player.seekTo(0L) + } + player.play() + } + } + + fun cycleSpeed() { + val currentIndex = PlaybackSpeeds.indexOfFirst { it == speed } + val nextIndex = (currentIndex + 1) % PlaybackSpeeds.size + player.playbackParameters = player.playbackParameters.withSpeed(PlaybackSpeeds[nextIndex]) + } + + fun onDragStart() { + isSeeking = true + } + + fun onDrag(dragProgress: Float) { + currentPosition = (dragProgress * duration).toLong() + } + + fun onDragStop(dragProgress: Float) { + currentPosition = (dragProgress * duration).toLong() + player.seekTo(currentPosition) + isSeeking = false + } + + suspend fun pollPosition() { + while (isPlaying) { + if (!isSeeking) { + currentPosition = player.currentPosition.coerceAtLeast(0L) + } + delay(PositionPollingIntervalMs) + } + } +} + +@Composable +private fun rememberVideoPlaybackControlsState(player: Player): VideoPlaybackControlsState { + val state = remember(player) { VideoPlaybackControlsState(player) } + + DisposableEffect(player) { + player.addListener(state.listener) + onDispose { player.removeListener(state.listener) } + } + + LaunchedEffect(player, state.isPlaying) { + state.pollPosition() + } + + return state +} diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channel/attachments/ChannelMediaAttachmentsPreviewScreen.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channel/attachments/ChannelMediaAttachmentsPreviewScreen.kt index 202ad352462..6684d5026ce 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channel/attachments/ChannelMediaAttachmentsPreviewScreen.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channel/attachments/ChannelMediaAttachmentsPreviewScreen.kt @@ -16,8 +16,10 @@ package io.getstream.chat.android.compose.ui.channel.attachments +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material3.ExperimentalMaterial3Api @@ -26,20 +28,26 @@ import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.media3.common.Player import io.getstream.chat.android.client.ChatClient -import io.getstream.chat.android.compose.R +import io.getstream.chat.android.client.utils.attachment.isVideo import io.getstream.chat.android.compose.handlers.LoadMoreHandler import io.getstream.chat.android.compose.ui.attachments.preview.ConfirmShareLargeFileDialog import io.getstream.chat.android.compose.ui.attachments.preview.MediaGalleryPager +import io.getstream.chat.android.compose.ui.attachments.preview.MediaGalleryPreviewPageIndicator import io.getstream.chat.android.compose.ui.attachments.preview.MediaGalleryPreviewShareIcon import io.getstream.chat.android.compose.ui.attachments.preview.MediaGalleryPreviewSharingInProgressIndicator +import io.getstream.chat.android.compose.ui.attachments.preview.internal.GalleryMediaEffect +import io.getstream.chat.android.compose.ui.attachments.preview.internal.VideoPlaybackControls +import io.getstream.chat.android.compose.ui.attachments.preview.internal.rememberMediaGalleryPlayerState import io.getstream.chat.android.compose.ui.theme.ChannelMediaAttachmentsPreviewBottomBarParams import io.getstream.chat.android.compose.ui.theme.ChatTheme import io.getstream.chat.android.compose.ui.util.StreamSnackbarHost @@ -136,40 +144,55 @@ private fun ChannelMediaAttachmentsPreviewContent( onVideoPlaybackError: (error: Throwable) -> Unit = {}, onShareClick: (attachment: Attachment) -> Unit = {}, ) { - val pagerState = rememberPagerState( - initialPage = initialIndex, - pageCount = items::size, - ) + val pagerState = rememberPagerState(initialPage = initialIndex, pageCount = items::size) val snackbarHostState = remember { SnackbarHostState() } + val attachments = remember(items) { + items.map(ChannelAttachmentsViewState.Content.Item::attachment) + } + val playerState = rememberMediaGalleryPlayerState(onPlaybackError = onVideoPlaybackError) + GalleryMediaEffect(playerState, pagerState.currentPage, attachments) + var isImmersive by remember { mutableStateOf(false) } + // Scaffold padding is intentionally ignored to prevent content shifting when toggling immersive mode + @Suppress("UnusedMaterial3ScaffoldPaddingParameter") Scaffold( modifier = Modifier.fillMaxSize(), topBar = { - ChatTheme.componentFactory.ChannelMediaAttachmentsPreviewTopBar( - item = items[pagerState.currentPage], - onNavigationIconClick = onNavigationIconClick, - ) + AnimatedVisibility( + visible = !isImmersive, + enter = fadeIn(), + exit = fadeOut(), + ) { + ChatTheme.componentFactory.ChannelMediaAttachmentsPreviewTopBar( + item = items[pagerState.currentPage], + onNavigationIconClick = onNavigationIconClick, + ) + } }, bottomBar = { - ChannelMediaAttachmentsPreviewBottomBar( - items = items, - viewState = viewState, - connectionState = connectionState, - pagerState = pagerState, - onShareClick = onShareClick, - ) + AnimatedVisibility( + visible = !isImmersive, + enter = fadeIn(), + exit = fadeOut(), + ) { + ChannelMediaAttachmentsPreviewBottomBar( + items = items, + viewState = viewState, + connectionState = connectionState, + pagerState = pagerState, + player = playerState.player, + onShareClick = onShareClick, + ) + } }, snackbarHost = { StreamSnackbarHost(hostState = snackbarHostState) }, containerColor = ChatTheme.colors.backgroundCoreApp, - ) { padding -> + ) { _ -> MediaGalleryPager( - modifier = Modifier - .padding(padding) - .fillMaxSize(), + modifier = Modifier.fillMaxSize(), pagerState = pagerState, - attachments = remember(items) { - items.map(ChannelAttachmentsViewState.Content.Item::attachment) - }, - onPlaybackError = onVideoPlaybackError, + attachments = attachments, + player = playerState.player, + onMediaClick = { isImmersive = !isImmersive }, ) LoadMoreHandler( pagerState = pagerState, @@ -180,28 +203,32 @@ private fun ChannelMediaAttachmentsPreviewContent( } @Composable +@Suppress("LongParameterList") private fun ChannelMediaAttachmentsPreviewBottomBar( items: List, viewState: ChannelMediaAttachmentsPreviewViewState, connectionState: ConnectionState, pagerState: PagerState, + player: Player?, onShareClick: (Attachment) -> Unit, ) { val config = ChatTheme.config.mediaGallery ChatTheme.componentFactory.ChannelMediaAttachmentsPreviewBottomBar( params = ChannelMediaAttachmentsPreviewBottomBarParams( + topContent = { + val currentAttachment = items.getOrNull(pagerState.currentPage)?.attachment + if (player != null && currentAttachment?.isVideo() == true) { + VideoPlaybackControls(player = player) + } + }, centerContent = { if (viewState.isPreparingToShare) { MediaGalleryPreviewSharingInProgressIndicator() } else { - // TODO Use MediaGalleryPreviewPageIndicator when this deprecated component is removed - ChatTheme.componentFactory.ChannelMediaAttachmentsPreviewBottomBar( - text = stringResource( - R.string.stream_ui_channel_attachments_media_preview_index, - pagerState.currentPage + 1, - items.size, - ), + MediaGalleryPreviewPageIndicator( + currentPage = pagerState.currentPage, + totalPages = items.size, ) } }, diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/NetworkLoadingIndicator.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/NetworkLoadingIndicator.kt index ba6e66a4374..a92a1786804 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/NetworkLoadingIndicator.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/NetworkLoadingIndicator.kt @@ -20,7 +20,6 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -53,12 +52,10 @@ public fun NetworkLoadingIndicator( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, ) { - CircularProgressIndicator( + LoadingIndicator( modifier = Modifier .padding(horizontal = 8.dp) .size(spinnerSize), - strokeWidth = 2.dp, - color = ChatTheme.colors.accentPrimary, ) Text( diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/common/PlaybackSpeedToggle.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/common/PlaybackSpeedToggle.kt index faf19eec57f..cf98da0174f 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/common/PlaybackSpeedToggle.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/common/PlaybackSpeedToggle.kt @@ -19,12 +19,17 @@ package io.getstream.chat.android.compose.ui.components.common import androidx.compose.foundation.border import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import io.getstream.chat.android.compose.ui.theme.ChatTheme @@ -43,15 +48,24 @@ internal fun PlaybackSpeedToggle( val colors = ChatTheme.colors val textColor = if (enabled) colors.controlPlaybackToggleText else colors.textDisabled val borderColor = if (enabled) outlineColor else colors.borderUtilityDisabled + + val textMeasurer = rememberTextMeasurer() + val style = ChatTheme.typography.metadataEmphasis + val minTextWidth = remember(textMeasurer, style) { + textMeasurer.measure("x1.5", style).size.width + } + Text( text = if (speed.isInt()) "x${speed.toInt()}" else "x$speed", - style = ChatTheme.typography.metadataEmphasis, + style = style, color = textColor, + textAlign = TextAlign.Center, modifier = Modifier .border(1.dp, borderColor, SpeedToggleShape) .clip(SpeedToggleShape) .applyIf(enabled) { clickable(onClick = onClick) } - .padding(horizontal = StreamTokens.spacingXs, vertical = StreamTokens.spacing2xs), + .padding(horizontal = StreamTokens.spacingXs, vertical = StreamTokens.spacing2xs) + .widthIn(min = with(LocalDensity.current) { minTextWidth.toDp() }), ) } diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/list/MessageItem.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/list/MessageItem.kt index d6d8f573949..45801e52ab6 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/list/MessageItem.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/list/MessageItem.kt @@ -48,7 +48,6 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import io.getstream.chat.android.compose.R import io.getstream.chat.android.compose.state.mediagallerypreview.MediaGalleryPreviewResult -import io.getstream.chat.android.compose.ui.components.StreamHorizontalDivider import io.getstream.chat.android.compose.ui.components.TypingIndicator import io.getstream.chat.android.compose.ui.components.avatar.AvatarSize import io.getstream.chat.android.compose.ui.components.avatar.UserAvatarStack @@ -59,6 +58,8 @@ import io.getstream.chat.android.compose.ui.theme.ChatPreviewTheme import io.getstream.chat.android.compose.ui.theme.ChatTheme import io.getstream.chat.android.compose.ui.theme.MessageStyling import io.getstream.chat.android.compose.ui.theme.StreamTokens +import io.getstream.chat.android.compose.ui.util.bottomBorder +import io.getstream.chat.android.compose.ui.util.topBorder import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.Option import io.getstream.chat.android.models.Poll @@ -216,31 +217,26 @@ internal fun DefaultMessageUnreadSeparatorContent(unreadSeparatorItemState: Unre @Composable internal fun DefaultMessageThreadSeparatorContent(threadSeparator: ThreadDateSeparatorItemState) { val replyCount = threadSeparator.replyCount + val colors = ChatTheme.colors - Box( + Text( modifier = Modifier - .semantics(mergeDescendants = true) {} + .padding(vertical = StreamTokens.spacingXs) + .topBorder(colors.borderCoreSubtle) + .bottomBorder(colors.borderCoreSubtle) .fillMaxWidth() - .padding(vertical = StreamTokens.spacingXs), - ) { - Text( - modifier = Modifier - .fillMaxWidth() - .background(ChatTheme.colors.backgroundCoreSurfaceSubtle) - .padding(horizontal = StreamTokens.spacingMd, vertical = StreamTokens.spacingXs) - .testTag("Stream_RepliesCount"), - text = pluralStringResource( - R.plurals.stream_compose_message_list_thread_separator, - replyCount, - replyCount, - ), - color = ChatTheme.colors.chatTextSystem, - style = ChatTheme.typography.metadataEmphasis, - textAlign = TextAlign.Center, - ) - StreamHorizontalDivider(modifier = Modifier.align(Alignment.TopCenter)) - StreamHorizontalDivider(modifier = Modifier.align(Alignment.BottomCenter)) - } + .background(colors.backgroundCoreSurfaceSubtle) + .padding(horizontal = StreamTokens.spacingMd, vertical = StreamTokens.spacingXs) + .testTag("Stream_RepliesCount"), + text = pluralStringResource( + R.plurals.stream_compose_message_list_thread_separator, + replyCount, + replyCount, + ), + color = colors.chatTextSystem, + style = ChatTheme.typography.metadataEmphasis, + textAlign = TextAlign.Center, + ) } /** diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactory.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactory.kt index a976527110f..9584aff2f96 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactory.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactory.kt @@ -26,7 +26,6 @@ import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize @@ -49,6 +48,7 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.SnackbarData import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults import androidx.compose.material3.pulltorefresh.PullToRefreshState import androidx.compose.runtime.Composable @@ -113,6 +113,9 @@ import io.getstream.chat.android.compose.ui.components.LoadingIndicator import io.getstream.chat.android.compose.ui.components.NetworkLoadingIndicator import io.getstream.chat.android.compose.ui.components.SearchInput import io.getstream.chat.android.compose.ui.components.StreamHorizontalDivider +import io.getstream.chat.android.compose.ui.components.button.StreamButton +import io.getstream.chat.android.compose.ui.components.button.StreamButtonSize +import io.getstream.chat.android.compose.ui.components.button.StreamButtonStyleDefaults import io.getstream.chat.android.compose.ui.components.channels.ChannelOptions import io.getstream.chat.android.compose.ui.components.channels.MessageReadStatusIcon import io.getstream.chat.android.compose.ui.components.channels.UnreadCountIndicator @@ -187,6 +190,8 @@ import io.getstream.chat.android.compose.ui.threads.ThreadItem import io.getstream.chat.android.compose.ui.threads.ThreadListBannerState import io.getstream.chat.android.compose.ui.util.ReactionResolver import io.getstream.chat.android.compose.ui.util.StreamSnackbar +import io.getstream.chat.android.compose.ui.util.bottomBorder +import io.getstream.chat.android.compose.ui.util.topBorder import io.getstream.chat.android.compose.viewmodel.messages.AttachmentsPickerViewModel import io.getstream.chat.android.compose.viewmodel.messages.AudioPlayerViewModelFactory import io.getstream.chat.android.compose.viewmodel.messages.MessageComposerViewModel @@ -3515,13 +3520,18 @@ public interface ChatComponentFactory { onNavigationIconClick: () -> Unit, ) { CenterAlignedTopAppBar( + modifier = Modifier.bottomBorder(ChatTheme.colors.borderCoreSubtle), title = { ChannelMediaAttachmentsPreviewTopBarTitle(item = item) }, navigationIcon = { - IconButton(onClick = onNavigationIconClick) { + StreamButton( + modifier = Modifier.minimumInteractiveComponentSize(), + onClick = onNavigationIconClick, + style = StreamButtonStyleDefaults.secondaryGhost, + size = StreamButtonSize.Medium, + ) { Icon( - painter = painterResource(id = R.drawable.stream_compose_ic_close), + painter = painterResource(id = R.drawable.stream_compose_ic_arrow_back), contentDescription = stringResource(id = R.string.stream_compose_cancel), - tint = ChatTheme.colors.textPrimary, ) } }, @@ -3548,13 +3558,13 @@ public interface ChatComponentFactory { ) { Text( text = title, - style = ChatTheme.typography.headingMedium, + style = ChatTheme.typography.headingSmall, color = ChatTheme.colors.textPrimary, maxLines = 1, ) Text( text = subtitle, - style = ChatTheme.typography.metadataDefault, + style = ChatTheme.typography.captionDefault, color = ChatTheme.colors.textSecondary, maxLines = 1, ) @@ -3563,57 +3573,33 @@ public interface ChatComponentFactory { /** * Factory method for creating the bottom bar of the channel media attachments preview screen. - * - * @param text The text to display in the bottom bar. */ - @Deprecated( - message = "Use ChannelMediaAttachmentsPreviewBottomBar(" + - "params: ChannelMediaAttachmentsPreviewBottomBarParams) instead.", - replaceWith = ReplaceWith( - "ChannelMediaAttachmentsPreviewBottomBar(ChannelMediaAttachmentsPreviewBottomBarParams(" + - "centerContent = { Text(text) }))", - ), - ) + @OptIn(ExperimentalMaterial3Api::class) @Composable - public fun ChannelMediaAttachmentsPreviewBottomBar(text: String) { - Row( + public fun ChannelMediaAttachmentsPreviewBottomBar( + params: ChannelMediaAttachmentsPreviewBottomBarParams, + ) { + Column( modifier = Modifier .background(ChatTheme.colors.backgroundElevationElevation1) - .padding(horizontal = 16.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, + .topBorder(ChatTheme.colors.borderCoreDefault), ) { - Text( - text = text, - style = ChatTheme.typography.headingMedium, - color = ChatTheme.colors.textPrimary, - maxLines = 1, + params.topContent?.invoke() + CenterAlignedTopAppBar( + title = { params.centerContent() }, + navigationIcon = { params.leadingContent() }, + actions = { params.trailingContent() }, + windowInsets = BottomAppBarDefaults.windowInsets, + colors = TopAppBarDefaults.centerAlignedTopAppBarColors( + containerColor = ChatTheme.colors.backgroundElevationElevation1, + titleContentColor = ChatTheme.colors.textPrimary, + navigationIconContentColor = ChatTheme.colors.textPrimary, + actionIconContentColor = ChatTheme.colors.textPrimary, + ), ) } } - /** - * Factory method for creating the bottom bar of the channel media attachments preview screen. - */ - @OptIn(ExperimentalMaterial3Api::class) - @Composable - public fun ChannelMediaAttachmentsPreviewBottomBar( - params: ChannelMediaAttachmentsPreviewBottomBarParams, - ) { - CenterAlignedTopAppBar( - title = { params.centerContent() }, - navigationIcon = { params.leadingContent() }, - actions = { params.trailingContent() }, - windowInsets = BottomAppBarDefaults.windowInsets, - colors = TopAppBarDefaults.centerAlignedTopAppBarColors( - containerColor = ChatTheme.colors.backgroundElevationElevation1, - titleContentColor = ChatTheme.colors.textPrimary, - navigationIconContentColor = ChatTheme.colors.textPrimary, - actionIconContentColor = ChatTheme.colors.textPrimary, - ), - ) - } - /** * Container component that manages the attachment picker's visibility and animations. * diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactoryParams.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactoryParams.kt index 789e8309386..3e1a4f455c0 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactoryParams.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactoryParams.kt @@ -46,11 +46,13 @@ public data class MessageReactionsParams( * @param centerContent Composable lambda for center content in the bottom bar. * @param leadingContent Composable lambda for leading content in the bottom bar. * @param trailingContent Composable lambda for trailing content in the bottom bar. + * @param topContent Composable lambda for content above the bottom bar (e.g. video playback controls). */ public data class ChannelMediaAttachmentsPreviewBottomBarParams( val centerContent: @Composable () -> Unit, val leadingContent: @Composable () -> Unit = {}, val trailingContent: @Composable () -> Unit = {}, + val topContent: @Composable (() -> Unit)? = null, ) /** diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/ModifierUtils.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/ModifierUtils.kt index d622fa18750..be6272d6999 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/ModifierUtils.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/ModifierUtils.kt @@ -21,8 +21,13 @@ import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.material3.ripple import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.ContentDrawScope import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp /** * Adds drag pointer input to the modifier. @@ -83,3 +88,27 @@ internal inline fun Modifier.applyIf(condition: Boolean, block: Modifier.() -> M internal inline fun Modifier.ifNotNull(value: T?, block: Modifier.(T) -> Modifier) = if (value != null) this.block(value) else this + +internal fun Modifier.bottomBorder(color: Color, width: Dp = 1.dp): Modifier = + verticalBorder(color, width, yPosition = { size.height - it / 2 }) + +internal fun Modifier.topBorder(color: Color, width: Dp = 1.dp): Modifier = + verticalBorder(color = color, width = width, yPosition = { it / 2 }) + +/** + * Draws a full-width border at the position returned by [yPosition]. + * + * @param color The color of the border. + * @param width The width of the border. + * @param yPosition A lambda that calculates the y position of the border based on the width in pixels. + */ +private inline fun Modifier.verticalBorder( + color: Color, + width: Dp, + crossinline yPosition: ContentDrawScope.(widthPx: Float) -> Float, +): Modifier = drawWithContent { + drawContent() + val widthPx = width.toPx() + val y = yPosition(widthPx) + drawLine(color = color, start = Offset(0f, y), end = Offset(size.width, y), strokeWidth = widthPx) +} diff --git a/stream-chat-android-compose/src/main/res/drawable/stream_compose_ic_share.xml b/stream-chat-android-compose/src/main/res/drawable/stream_compose_ic_share.xml index 6bc86623045..a0f50d673cc 100644 --- a/stream-chat-android-compose/src/main/res/drawable/stream_compose_ic_share.xml +++ b/stream-chat-android-compose/src/main/res/drawable/stream_compose_ic_share.xml @@ -15,13 +15,13 @@ limitations under the License. --> + android:width="16dp" + android:height="16dp" + android:viewportWidth="16" + android:viewportHeight="16"> + android:pathData="M6,6.952L9.333,5.048M6,9.048L9.333,10.952M13.5,4C13.5,5.197 12.53,6.167 11.333,6.167C10.137,6.167 9.167,5.197 9.167,4C9.167,2.803 10.137,1.833 11.333,1.833C12.53,1.833 13.5,2.803 13.5,4ZM13.5,12C13.5,13.197 12.53,14.167 11.333,14.167C10.137,14.167 9.167,13.197 9.167,12C9.167,10.803 10.137,9.833 11.333,9.833C12.53,9.833 13.5,10.803 13.5,12ZM6.167,8C6.167,9.197 5.197,10.167 4,10.167C2.803,10.167 1.833,9.197 1.833,8C1.833,6.803 2.803,5.833 4,5.833C5.197,5.833 6.167,6.803 6.167,8Z" + android:strokeWidth="1.2" + android:fillColor="#00000000" + android:strokeColor="#000000"/> diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/attachments/preview/MediaGalleryPreviewActivityTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/attachments/preview/MediaGalleryPreviewActivityTest.kt index c71033abf21..d16bfc11724 100644 --- a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/attachments/preview/MediaGalleryPreviewActivityTest.kt +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/attachments/preview/MediaGalleryPreviewActivityTest.kt @@ -18,10 +18,14 @@ package io.getstream.chat.android.compose.ui.attachments.preview import android.app.Activity import android.content.Intent +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performSemanticsAction import androidx.test.core.app.ActivityScenario import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -78,7 +82,10 @@ internal class MediaGalleryPreviewActivityTest : MockedChatClientTest { composeTestRule.waitForIdle() composeTestRule.onNodeWithContentDescription("Image options").performClick() - composeTestRule.onNodeWithText("Reply").performClick() + composeTestRule.waitForIdle() + composeTestRule.onNode(hasText("Reply") and hasClickAction()) + .performSemanticsAction(SemanticsActions.OnClick) + composeTestRule.waitForIdle() scenario.assertResult( expected = MediaGalleryPreviewResult( @@ -99,7 +106,10 @@ internal class MediaGalleryPreviewActivityTest : MockedChatClientTest { composeTestRule.waitForIdle() composeTestRule.onNodeWithContentDescription("Image options").performClick() - composeTestRule.onNodeWithText("Show in chat").performClick() + composeTestRule.waitForIdle() + composeTestRule.onNode(hasText("Show in chat") and hasClickAction()) + .performSemanticsAction(SemanticsActions.OnClick) + composeTestRule.waitForIdle() scenario.assertResult( expected = MediaGalleryPreviewResult( @@ -122,8 +132,10 @@ internal class MediaGalleryPreviewActivityTest : MockedChatClientTest { composeTestRule.waitForIdle() composeTestRule.onNodeWithContentDescription("Image options").performClick() + composeTestRule.waitForIdle() - composeTestRule.onNodeWithText("Delete").performClick() + composeTestRule.onNode(hasText("Delete") and hasClickAction()) + .performSemanticsAction(SemanticsActions.OnClick) } } @@ -136,8 +148,10 @@ internal class MediaGalleryPreviewActivityTest : MockedChatClientTest { composeTestRule.waitForIdle() composeTestRule.onNodeWithContentDescription("Image options").performClick() + composeTestRule.waitForIdle() - composeTestRule.onNodeWithText("Save media").performClick() + composeTestRule.onNode(hasText("Save media") and hasClickAction()) + .performSemanticsAction(SemanticsActions.OnClick) } } diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.content_AttachmentsContentTest_audio_record_attachment_content.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.content_AttachmentsContentTest_audio_record_attachment_content.png index 5e1772dd61b..275a14a8bd0 100644 Binary files a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.content_AttachmentsContentTest_audio_record_attachment_content.png and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.content_AttachmentsContentTest_audio_record_attachment_content.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.content_AttachmentsContentTest_audio_record_upload_content.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.content_AttachmentsContentTest_audio_record_upload_content.png index 08143d704a0..5c46a248866 100644 Binary files a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.content_AttachmentsContentTest_audio_record_upload_content.png and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.content_AttachmentsContentTest_audio_record_upload_content.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewOptionsMenuTest_media_gallery_options_menu_for_other_user.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewOptionsMenuTest_media_gallery_options_menu_for_other_user.png index bd47597ff1a..b41cd8760ff 100644 Binary files a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewOptionsMenuTest_media_gallery_options_menu_for_other_user.png and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewOptionsMenuTest_media_gallery_options_menu_for_other_user.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewOptionsMenuTest_media_gallery_options_menu_for_own_user.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewOptionsMenuTest_media_gallery_options_menu_for_own_user.png index 4b0283edca0..176417e5361 100644 Binary files a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewOptionsMenuTest_media_gallery_options_menu_for_own_user.png and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewOptionsMenuTest_media_gallery_options_menu_for_own_user.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewScreenTest_media_gallery_footer_connected.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewScreenTest_media_gallery_footer_connected.png index b437e834937..f78912c6fbe 100644 Binary files a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewScreenTest_media_gallery_footer_connected.png and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewScreenTest_media_gallery_footer_connected.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewScreenTest_media_gallery_footer_offline.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewScreenTest_media_gallery_footer_offline.png index ac58faefcd7..b891a563acc 100644 Binary files a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewScreenTest_media_gallery_footer_offline.png and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewScreenTest_media_gallery_footer_offline.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewScreenTest_media_gallery_footer_sharing_in_progress.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewScreenTest_media_gallery_footer_sharing_in_progress.png index d70eec19301..167f19574c3 100644 Binary files a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewScreenTest_media_gallery_footer_sharing_in_progress.png and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewScreenTest_media_gallery_footer_sharing_in_progress.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewScreenTest_media_gallery_header_connecting.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewScreenTest_media_gallery_header_connecting.png index cc519819c7d..619bed427eb 100644 Binary files a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewScreenTest_media_gallery_header_connecting.png and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewScreenTest_media_gallery_header_connecting.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewScreenTest_media_gallery_header_message_without_id.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewScreenTest_media_gallery_header_message_without_id.png index 62321337745..d2897ea54ab 100644 Binary files a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewScreenTest_media_gallery_header_message_without_id.png and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewScreenTest_media_gallery_header_message_without_id.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewScreenTest_media_gallery_header_offline.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewScreenTest_media_gallery_header_offline.png index f80a1c9c604..05b138a96de 100644 Binary files a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewScreenTest_media_gallery_header_offline.png and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewScreenTest_media_gallery_header_offline.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewScreenTest_media_gallery_header_online.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewScreenTest_media_gallery_header_online.png index 1e9629772d6..38782955522 100644 Binary files a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewScreenTest_media_gallery_header_online.png and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewScreenTest_media_gallery_header_online.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewScreenTest_media_gallery_screen_connected.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewScreenTest_media_gallery_screen_connected.png index ba58fe123ad..ee89360b710 100644 Binary files a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewScreenTest_media_gallery_screen_connected.png and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewScreenTest_media_gallery_screen_connected.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewScreenTest_media_gallery_screen_offline.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewScreenTest_media_gallery_screen_offline.png index c3cd24c7cb6..522bc9fd0ae 100644 Binary files a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewScreenTest_media_gallery_screen_offline.png and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewScreenTest_media_gallery_screen_offline.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewScreenTest_media_gallery_screen_with_gallery_bottom_sheet.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewScreenTest_media_gallery_screen_with_gallery_bottom_sheet.png index 2434d2305d8..da50c6723e3 100644 Binary files a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewScreenTest_media_gallery_screen_with_gallery_bottom_sheet.png and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewScreenTest_media_gallery_screen_with_gallery_bottom_sheet.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewScreenTest_media_gallery_screen_with_options_menu.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewScreenTest_media_gallery_screen_with_options_menu.png index 3e2d4a1e5fb..c833eb4ad97 100644 Binary files a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewScreenTest_media_gallery_screen_with_options_menu.png and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewScreenTest_media_gallery_screen_with_options_menu.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewScreenTest_media_gallery_screen_with_share_large_file_prompt.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewScreenTest_media_gallery_screen_with_share_large_file_prompt.png index 7d999d9b244..242f0bbdd1e 100644 Binary files a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewScreenTest_media_gallery_screen_with_share_large_file_prompt.png and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewScreenTest_media_gallery_screen_with_share_large_file_prompt.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.attachments_ChannelMediaAttachmentsPreviewContentTest_content.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.attachments_ChannelMediaAttachmentsPreviewContentTest_content.png index 13ecd5deaaa..1d4a4c678fa 100644 Binary files a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.attachments_ChannelMediaAttachmentsPreviewContentTest_content.png and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.attachments_ChannelMediaAttachmentsPreviewContentTest_content.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.attachments_ChannelMediaAttachmentsPreviewContentTest_content_in_dark_mode.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.attachments_ChannelMediaAttachmentsPreviewContentTest_content_in_dark_mode.png index 798c60a0410..a6e89ca415a 100644 Binary files a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.attachments_ChannelMediaAttachmentsPreviewContentTest_content_in_dark_mode.png and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.attachments_ChannelMediaAttachmentsPreviewContentTest_content_in_dark_mode.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.attachments_ChannelMediaAttachmentsPreviewContentTest_preparing_to_share_content.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.attachments_ChannelMediaAttachmentsPreviewContentTest_preparing_to_share_content.png index 1495c22f1b3..933cc868287 100644 Binary files a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.attachments_ChannelMediaAttachmentsPreviewContentTest_preparing_to_share_content.png and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.attachments_ChannelMediaAttachmentsPreviewContentTest_preparing_to_share_content.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.attachments_ChannelMediaAttachmentsPreviewContentTest_preparing_to_share_content_in_dark_mode.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.attachments_ChannelMediaAttachmentsPreviewContentTest_preparing_to_share_content_in_dark_mode.png index f8abae7aa29..1b0a23c0903 100644 Binary files a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.attachments_ChannelMediaAttachmentsPreviewContentTest_preparing_to_share_content_in_dark_mode.png and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channel.attachments_ChannelMediaAttachmentsPreviewContentTest_preparing_to_share_content_in_dark_mode.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channels_ChannelListHeaderTest_connecting,_no_user.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channels_ChannelListHeaderTest_connecting,_no_user.png index 0536ca19427..c75a5b8b264 100644 Binary files a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channels_ChannelListHeaderTest_connecting,_no_user.png and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channels_ChannelListHeaderTest_connecting,_no_user.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channels_ChannelListHeaderTest_connecting,_with_user.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channels_ChannelListHeaderTest_connecting,_with_user.png index e97213379a7..d1decf4236f 100644 Binary files a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channels_ChannelListHeaderTest_connecting,_with_user.png and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channels_ChannelListHeaderTest_connecting,_with_user.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.messages.composer.internal.attachments_MessageComposerAttachmentsTest_audio_record_attachment_item.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.messages.composer.internal.attachments_MessageComposerAttachmentsTest_audio_record_attachment_item.png index d5d2a1a944a..4fe4186b4e4 100644 Binary files a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.messages.composer.internal.attachments_MessageComposerAttachmentsTest_audio_record_attachment_item.png and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.messages.composer.internal.attachments_MessageComposerAttachmentsTest_audio_record_attachment_item.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.messages.composer.internal.attachments_MessageComposerAttachmentsTest_message_composer_attachments.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.messages.composer.internal.attachments_MessageComposerAttachmentsTest_message_composer_attachments.png index 9b06e38d029..406404557e3 100644 Binary files a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.messages.composer.internal.attachments_MessageComposerAttachmentsTest_message_composer_attachments.png and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.messages.composer.internal.attachments_MessageComposerAttachmentsTest_message_composer_attachments.png differ