From 918fc164ccf240d52efca6dcc32675e9c6e37c48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Saleniuk?= Date: Thu, 23 Apr 2026 15:03:01 +0200 Subject: [PATCH 01/10] feat: make bottom calling controls a draggable bottom sheet [WPB-1057] --- .../ui/calling/ongoing/OngoingCallScreen.kt | 190 ++++++++++++------ .../ongoing/fullscreen/FullScreenTile.kt | 17 +- .../banner/SecurityClassificationBanner.kt | 14 ++ .../bottomsheet/WireBottomSheetDefaults.kt | 16 -- .../bottomsheet/WireBottomSheetScaffold.kt | 101 +++++++++- .../ui/common/bottomsheet/WireDragHandle.kt | 84 ++++++++ .../bottomsheet/WireModalSheetLayout.kt | 2 +- .../wire/android/ui/theme/WireDimensions.kt | 3 - .../feature/sketch/DrawingToolPicker.kt | 12 +- 9 files changed, 331 insertions(+), 108 deletions(-) create mode 100644 core/ui-common/src/main/kotlin/com/wire/android/ui/common/bottomsheet/WireDragHandle.kt diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallScreen.kt b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallScreen.kt index ff19c843741..04841a28afc 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallScreen.kt @@ -16,6 +16,7 @@ * along with this program. If not, see http://www.gnu.org/licenses/. */ @file:Suppress("TooManyFunctions") +@file:OptIn(ExperimentalMaterial3Api::class) package com.wire.android.ui.calling.ongoing @@ -27,15 +28,19 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints 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.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding +import androidx.compose.material3.BottomSheetDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SheetValue import androidx.compose.material3.Text +import androidx.compose.material3.rememberBottomSheetScaffoldState +import androidx.compose.material3.rememberStandardBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -47,7 +52,11 @@ import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInParent +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -88,14 +97,17 @@ import com.wire.android.ui.calling.ongoing.toast.InCallToast import com.wire.android.ui.calling.ongoing.toast.InCallToastPanel import com.wire.android.ui.common.ConversationVerificationIcons import com.wire.android.ui.common.HandleActions +import com.wire.android.ui.common.banner.PreviewSecurityClassificationBannerState import com.wire.android.ui.common.banner.SecurityClassificationBannerForConversation +import com.wire.android.ui.common.bottomsheet.WireBottomSheetScaffold +import com.wire.android.ui.common.bottomsheet.WireBottomSheetScaffoldProperties +import com.wire.android.ui.common.bottomsheet.WireDragHandle import com.wire.android.ui.common.bottomsheet.WireSheetValue import com.wire.android.ui.common.bottomsheet.rememberWireModalSheetState import com.wire.android.ui.common.colorsScheme import com.wire.android.ui.common.dialogs.PermissionPermanentlyDeniedDialog import com.wire.android.ui.common.dimensions import com.wire.android.ui.common.progress.WireCircularProgressIndicator -import com.wire.android.ui.common.scaffold.WireScaffold import com.wire.android.ui.common.topappbar.NavigationIconType import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar import com.wire.android.ui.common.visbility.rememberVisibilityState @@ -115,6 +127,7 @@ import com.wire.kalium.logic.data.conversation.Conversation import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.data.id.QualifiedID import com.wire.kalium.logic.data.user.UserId +import com.wire.kalium.logic.feature.conversation.SecurityClassificationType import kotlinx.collections.immutable.PersistentList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList @@ -197,6 +210,14 @@ fun OngoingCallScreen( } } + BackHandler { + when { + ongoingCallViewModel.state.selectedParticipant != null -> ongoingCallViewModel.onSelectedParticipant(null) + shouldUsePiPMode -> (activity as OngoingCallActivity).enterPiPMode(conversationId, ongoingCallViewModel.currentUserId) + else -> activity.moveTaskToBack(true) + } + } + OngoingCallContent( callState = sharedCallingViewModel.callState, inCallReactionsState = inCallReactionsState, @@ -226,17 +247,6 @@ fun OngoingCallScreen( ) ObserveRotation(sharedCallingViewModel::setUIRotation) - BackHandler { - if (shouldUsePiPMode) { - (activity as OngoingCallActivity).enterPiPMode( - conversationId, - ongoingCallViewModel.currentUserId - ) - } else { - activity.moveTaskToBack(true) - } - } - /** * Enter PiP mode when the user leaves the app by pressing the home button. */ @@ -343,7 +353,6 @@ private fun HandleSendingVideoFeed( } @Suppress("CyclomaticComplexMethod") -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun OngoingCallContent( callState: CallState, @@ -371,12 +380,23 @@ private fun OngoingCallContent( onToastClick: (toastKey: InCallToast.Key) -> Unit, inCallReactionsEnabled: Boolean = BuildConfig.CALL_REACTIONS_ENABLED, initialShowInCallReactionsPanel: Boolean = false, // for preview purposes + sheetInitialValue: SheetValue = SheetValue.PartiallyExpanded, // for preview purposes ) { + var sheetPeekHeight by remember { mutableStateOf(0f) } + var sheetExpandableHeight by remember { mutableStateOf(0f) } + var topBarHeight by remember { mutableStateOf(0f) } + val sheetState = rememberStandardBottomSheetState( + initialValue = sheetInitialValue, + confirmValueChange = { targetValue -> + // do not allow to expand the sheet if there is nothing more to show in the expanded state (height is 0) + !(targetValue == SheetValue.Expanded && sheetExpandableHeight <= 0f) + } + ) + val scaffoldState = rememberBottomSheetScaffoldState(bottomSheetState = sheetState) var showInCallReactionsPanel by remember { mutableStateOf(initialShowInCallReactionsPanel && inCallReactionsEnabled) } val emojiPickerState = rememberWireModalSheetState(skipPartiallyExpanded = false) val isConnecting = participants.isEmpty() - - WireScaffold( + WireBottomSheetScaffold( topBar = { if (!inPictureInPictureMode) { OngoingCallTopBar( @@ -391,18 +411,74 @@ private fun OngoingCallContent( mlsVerificationStatus = callState.mlsVerificationStatus, proteusVerificationStatus = callState.proteusVerificationStatus, callQuality = callQuality, - onOpenCallDetails = onOpenCallDetails + onOpenCallDetails = onOpenCallDetails, + modifier = Modifier.onGloballyPositioned { + topBarHeight = it.size.height.toFloat() + } ) } - } - ) { + }, + sheetDragHandle = { + WireDragHandle(progress = if (sheetExpandableHeight == 0f || sheetState.targetValue == SheetValue.Expanded) 0f else 1f) + }, + sheetPeekHeight = with(LocalDensity.current) { sheetPeekHeight.toDp() }, + scaffoldState = scaffoldState, + sheetShadowElevation = dimensions().spacing0x, + sheetMaxWidth = LocalConfiguration.current.screenWidthDp.dp, + properties = WireBottomSheetScaffoldProperties( + shouldDismissOnBackPress = true, + shouldDismissOnClickOutside = true, + dismissToPartiallyExpanded = true, + scrimColor = BottomSheetDefaults.ScrimColor + ), + sheetContent = { + if (!inPictureInPictureMode) { + CallingControls( + conversationId = callState.conversationId, + isMuted = callState.isMuted ?: true, + isCameraOn = callState.isCameraOn, + isSpeakerOn = callState.isSpeakerOn, + isShowingCallReactions = showInCallReactionsPanel, + isConnecting = isConnecting, + inCallReactionsEnabled = inCallReactionsEnabled, + toggleSpeaker = toggleSpeaker, + toggleMute = toggleMute, + onHangUpCall = hangUpCall, + onToggleVideo = toggleVideo, + onCallReactionsClick = { + showInCallReactionsPanel = !showInCallReactionsPanel + }, + onCameraPermissionPermanentlyDenied = onCameraPermissionPermanentlyDenied, + modifier = Modifier.onGloballyPositioned { + sheetPeekHeight = it.positionInParent().y + it.size.height.toFloat() + } + ) + BoxWithConstraints { + Column( + modifier = Modifier + .heightIn(max = with(LocalDensity.current) { (constraints.maxHeight - topBarHeight).toDp() }) + .onGloballyPositioned { + sheetExpandableHeight = it.size.height.toFloat() + } + ) { + Box( + modifier = Modifier // TODO: replace with proper list of participants + .fillMaxWidth() + .height(0.dp) + ) + } + } + } + }, + ) { internalPadding -> Column( - modifier = Modifier.padding(it) + modifier = Modifier + .padding(internalPadding) + .fillMaxSize() ) { - BoxWithConstraints( modifier = Modifier - .fillMaxSize() + .fillMaxWidth() .weight(1f) ) { LaunchedEffect(this.maxHeight.value) { @@ -457,10 +533,7 @@ private fun OngoingCallContent( callState = callState, selectedParticipant = uiCallParticipantToShowOnFullScreen, height = this@BoxWithConstraints.maxHeight, - closeFullScreen = { - onSelectedParticipant(null) - }, - onBackButtonClicked = { + onDoubleTap = { onSelectedParticipant(null) }, requestVideoStreams = requestVideoStreams, @@ -514,34 +587,13 @@ private fun OngoingCallContent( } } } - - if (!inPictureInPictureMode) { - if (showInCallReactionsPanel && inCallReactionsEnabled) { - InCallReactionsPanel( - onReactionClick = onReactionClick, - onMoreClick = { emojiPickerState.show(Unit) }, - modifier = Modifier.align(Alignment.CenterHorizontally), - ) - } - CallingControls( - conversationId = callState.conversationId, - isMuted = callState.isMuted ?: true, - isCameraOn = callState.isCameraOn, - isSpeakerOn = callState.isSpeakerOn, - isShowingCallReactions = showInCallReactionsPanel, - isConnecting = isConnecting, - inCallReactionsEnabled = inCallReactionsEnabled, - toggleSpeaker = toggleSpeaker, - toggleMute = toggleMute, - onHangUpCall = hangUpCall, - onToggleVideo = toggleVideo, - onCallReactionsClick = { - showInCallReactionsPanel = !showInCallReactionsPanel - }, - onCameraPermissionPermanentlyDenied = onCameraPermissionPermanentlyDenied + if (!inPictureInPictureMode && showInCallReactionsPanel && inCallReactionsEnabled) { + InCallReactionsPanel( + onReactionClick = onReactionClick, + onMoreClick = { emojiPickerState.show(Unit) }, + modifier = Modifier.align(Alignment.CenterHorizontally), ) } - EmojiPickerBottomSheet( sheetState = emojiPickerState, onEmojiSelected = { emoji, _ -> @@ -562,9 +614,10 @@ private fun OngoingCallTopBar( proteusVerificationStatus: Conversation.VerificationStatus?, callQuality: CallQualityData.Quality, onCollapse: () -> Unit, - onOpenCallDetails: () -> Unit + onOpenCallDetails: () -> Unit, + modifier: Modifier = Modifier ) { - Column { + Column(modifier = modifier) { WireCenterAlignedTopAppBar( onNavigationPressed = onCollapse, titleContent = { @@ -625,18 +678,16 @@ private fun CallingControls( onHangUpCall: () -> Unit, onToggleVideo: () -> Unit, onCallReactionsClick: () -> Unit, - onCameraPermissionPermanentlyDenied: () -> Unit + onCameraPermissionPermanentlyDenied: () -> Unit, + modifier: Modifier = Modifier ) { - Column( - modifier = Modifier.height(dimensions().defaultSheetPeekHeight) - ) { - Spacer(modifier = Modifier.weight(1F)) + Column(modifier = modifier) { Row( horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() - .height(dimensions().spacing56x) + .padding(top = dimensions().spacing4x, bottom = dimensions().spacing8x) ) { MicrophoneButton( isMuted = isMuted, @@ -665,7 +716,6 @@ private fun CallingControls( onHangUpButtonClicked = onHangUpCall ) } - Spacer(modifier = Modifier.weight(1F)) SecurityClassificationBannerForConversation(conversationId) } } @@ -675,7 +725,8 @@ private fun CallingControls( fun PreviewOngoingCallContent( participants: PersistentList, inCallReactionsPanelVisible: Boolean = false, - toasts: Set = emptySet() + toasts: Set = emptySet(), + sheetValue: SheetValue = SheetValue.PartiallyExpanded, ) { OngoingCallContent( callState = CallState( @@ -714,6 +765,7 @@ fun PreviewOngoingCallContent( othersVideosDisabled = true, toasts = toasts, onToastClick = {}, + sheetInitialValue = sheetValue, ) } @@ -752,6 +804,20 @@ fun PreviewOngoingCallScreenConnecting() = WireTheme { PreviewOngoingCallContent(participants = persistentListOf()) } +@PreviewMultipleThemes +@Composable +fun PreviewOngoingCallScreen_WithSecurityBanner() = WireTheme { + PreviewSecurityClassificationBannerState(SecurityClassificationType.NOT_CLASSIFIED) { + PreviewOngoingCallContent(participants = buildPreviewParticipantsList(2)) + } +} + +@PreviewMultipleThemes +@Composable +fun PreviewOngoingCallScreen_WithParticipantsListExpanded() = WireTheme { + PreviewOngoingCallContent(participants = buildPreviewParticipantsList(3), sheetValue = SheetValue.Expanded) +} + @PreviewMultipleThemes @Composable fun PreviewOngoingCallScreen_WithToasts() = WireTheme { diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/fullscreen/FullScreenTile.kt b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/fullscreen/FullScreenTile.kt index 1bffedf2a0e..3e61e6e3b7b 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/fullscreen/FullScreenTile.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/fullscreen/FullScreenTile.kt @@ -18,7 +18,6 @@ package com.wire.android.ui.calling.ongoing.fullscreen import android.view.View -import androidx.activity.compose.BackHandler import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth @@ -46,8 +45,7 @@ fun FullScreenTile( callState: CallState, selectedParticipant: UICallParticipant, height: Dp, - closeFullScreen: (offset: Offset) -> Unit, - onBackButtonClicked: () -> Unit, + onDoubleTap: (offset: Offset) -> Unit, setVideoPreview: (View) -> Unit, requestVideoStreams: (participants: List) -> Unit, clearVideoPreview: () -> Unit, @@ -57,20 +55,14 @@ fun FullScreenTile( modifier: Modifier = Modifier, contentPadding: Dp = dimensions().spacing4x, ) { - BackHandler { - onBackButtonClicked() - } - selectedParticipant.let { Box(modifier = modifier) { ParticipantTile( modifier = Modifier .fillMaxWidth() .clipToBounds() - .pointerInput(Unit) { - detectTapGestures( - onDoubleTap = closeFullScreen - ) + .pointerInput(onDoubleTap) { + detectTapGestures(onDoubleTap = onDoubleTap) } .height(height) .padding(contentPadding), @@ -110,8 +102,7 @@ fun PreviewFullScreenTile() = WireTheme { ), selectedParticipant = buildPreviewParticipantsList(1).first(), height = 800.dp, - closeFullScreen = {}, - onBackButtonClicked = {}, + onDoubleTap = {}, setVideoPreview = {}, requestVideoStreams = {}, clearVideoPreview = {}, diff --git a/app/src/main/kotlin/com/wire/android/ui/common/banner/SecurityClassificationBanner.kt b/app/src/main/kotlin/com/wire/android/ui/common/banner/SecurityClassificationBanner.kt index d7c222b77f4..77cb72b4c1f 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/banner/SecurityClassificationBanner.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/banner/SecurityClassificationBanner.kt @@ -31,10 +31,13 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import com.wire.android.R @@ -91,6 +94,7 @@ private fun SecurityClassificationBanner( state: SecurityClassificationType, modifier: Modifier = Modifier ) { + val state = if (LocalInspectionMode.current) (LocalPreviewSecurityClassificationBannerState.current ?: state) else state if (state != SecurityClassificationType.NONE) { Column(modifier = modifier) { HorizontalDivider(color = getDividerColorFor(state)) @@ -119,6 +123,16 @@ private fun SecurityClassificationBanner( } } +@Composable +fun PreviewSecurityClassificationBannerState(state: SecurityClassificationType, content: @Composable () -> Unit) { + CompositionLocalProvider(LocalPreviewSecurityClassificationBannerState provides state) { + content() + } + } + +private val LocalPreviewSecurityClassificationBannerState = + staticCompositionLocalOf { null } + @Composable private fun getTextFor(securityClassificationType: SecurityClassificationType): String { return if (securityClassificationType == SecurityClassificationType.CLASSIFIED) { diff --git a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/bottomsheet/WireBottomSheetDefaults.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/bottomsheet/WireBottomSheetDefaults.kt index 4a2cdcc1e68..c66fc6d1af5 100644 --- a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/bottomsheet/WireBottomSheetDefaults.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/bottomsheet/WireBottomSheetDefaults.kt @@ -17,13 +17,8 @@ */ package com.wire.android.ui.common.bottomsheet -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.unit.Dp @@ -39,15 +34,4 @@ object WireBottomSheetDefaults { val WireContainerColor: Color @Composable get() = colorsScheme().background val WireContentColor: Color @Composable get() = colorsScheme().onBackground val WireSheetTonalElevation: Dp @Composable get() = 0.dp - - @Composable - fun WireDragHandle(modifier: Modifier = Modifier) { - Box( - modifier - .padding(vertical = dimensions().spacing12x) - .size(width = dimensions().modalBottomSheetDividerWidth, height = dimensions().spacing4x) - .background(color = colorsScheme().secondaryText, shape = RoundedCornerShape(size = dimensions().spacing2x)) - - ) - } } diff --git a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/bottomsheet/WireBottomSheetScaffold.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/bottomsheet/WireBottomSheetScaffold.kt index ce0be5b0771..cbcbf7d9411 100644 --- a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/bottomsheet/WireBottomSheetScaffold.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/bottomsheet/WireBottomSheetScaffold.kt @@ -17,22 +17,39 @@ */ package com.wire.android.ui.common.bottomsheet +import androidx.activity.compose.BackHandler +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.material3.BottomSheetDefaults import androidx.compose.material3.BottomSheetScaffold import androidx.compose.material3.BottomSheetScaffoldState import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SheetValue import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.rememberBottomSheetScaffoldState import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.isSpecified +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.onClick +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.traversalIndex import androidx.compose.ui.unit.Dp +import kotlinx.coroutines.launch +import androidx.compose.ui.R as ComposeUiR @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -41,25 +58,38 @@ fun WireBottomSheetScaffold( modifier: Modifier = Modifier, scaffoldState: BottomSheetScaffoldState = rememberBottomSheetScaffoldState(), sheetPeekHeight: Dp = BottomSheetDefaults.SheetPeekHeight, + sheetMaxWidth: Dp = BottomSheetDefaults.SheetMaxWidth, sheetShape: Shape = WireBottomSheetDefaults.WireBottomSheetShape, sheetContainerColor: Color = WireBottomSheetDefaults.WireSheetContainerColor, sheetContentColor: Color = WireBottomSheetDefaults.WireSheetContentColor, sheetTonalElevation: Dp = WireBottomSheetDefaults.WireSheetTonalElevation, sheetShadowElevation: Dp = BottomSheetDefaults.Elevation, - sheetDragHandle: @Composable (() -> Unit)? = { WireBottomSheetDefaults.WireDragHandle() }, + sheetDragHandle: @Composable (() -> Unit)? = { WireDragHandle() }, sheetSwipeEnabled: Boolean = true, topBar: @Composable (() -> Unit)? = null, snackbarHost: @Composable (SnackbarHostState) -> Unit = { SnackbarHost(it) }, containerColor: Color = WireBottomSheetDefaults.WireContainerColor, contentColor: Color = WireBottomSheetDefaults.WireContentColor, + properties: WireBottomSheetScaffoldProperties = WireBottomSheetScaffoldProperties(), content: @Composable (PaddingValues) -> Unit ) { + val scope = rememberCoroutineScope() + val dismissSheet: () -> Unit = { + scope.launch { + if (properties.dismissToPartiallyExpanded) { + scaffoldState.bottomSheetState.partialExpand() + } else { + scaffoldState.bottomSheetState.hide() + } + } + } Box(modifier = Modifier.navigationBarsPadding()) { BottomSheetScaffold( sheetContent = sheetContent, modifier = modifier, scaffoldState = scaffoldState, sheetPeekHeight = sheetPeekHeight, + sheetMaxWidth = sheetMaxWidth, sheetShape = sheetShape, sheetContainerColor = sheetContainerColor, sheetContentColor = sheetContentColor, @@ -67,11 +97,76 @@ fun WireBottomSheetScaffold( sheetShadowElevation = sheetShadowElevation, sheetDragHandle = sheetDragHandle, sheetSwipeEnabled = sheetSwipeEnabled, - topBar = topBar, + topBar = topBar?.let { topBar -> + { + Box { + topBar() + Scrim( + properties = properties, + onDismissRequest = dismissSheet, + visible = scaffoldState.bottomSheetState.targetValue == SheetValue.Expanded, + modifier = Modifier.matchParentSize() + ) + } + } + }, snackbarHost = snackbarHost, containerColor = containerColor, contentColor = contentColor, - content = content + content = { paddingValues -> + content(paddingValues) + Scrim( + properties = properties, + onDismissRequest = dismissSheet, + visible = scaffoldState.bottomSheetState.targetValue == SheetValue.Expanded, + modifier = Modifier.fillMaxSize() + ) + } ) + BackHandler(enabled = properties.shouldDismissOnBackPress && scaffoldState.bottomSheetState.currentValue == SheetValue.Expanded) { + dismissSheet() + } + } +} + +data class WireBottomSheetScaffoldProperties( + val shouldDismissOnBackPress: Boolean = false, + val shouldDismissOnClickOutside: Boolean = false, + val dismissToPartiallyExpanded: Boolean = true, // otherwise it will dismiss to hidden state + val scrimColor: Color = Color.Unspecified, +) + +@Composable +private fun Scrim( + properties: WireBottomSheetScaffoldProperties, + onDismissRequest: () -> Unit, + visible: Boolean, + modifier: Modifier = Modifier, +) { + val alpha by animateFloatAsState(targetValue = if (visible) 1f else 0f) + val closeSheet = stringResource(ComposeUiR.string.close_sheet) + Canvas( + modifier.let { + if (visible && properties.shouldDismissOnClickOutside) { + it + .pointerInput(onDismissRequest) { + detectTapGestures { onDismissRequest() } + } + .semantics(mergeDescendants = true) { + traversalIndex = 1f + contentDescription = closeSheet + onClick { + onDismissRequest() + true + } + } + } else { + it + } + } + ) { + if (properties.scrimColor.isSpecified) { + drawRect(color = properties.scrimColor, alpha = alpha.coerceIn(0f, 1f)) + } } } diff --git a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/bottomsheet/WireDragHandle.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/bottomsheet/WireDragHandle.kt new file mode 100644 index 00000000000..dc1b25b5f9d --- /dev/null +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/bottomsheet/WireDragHandle.kt @@ -0,0 +1,84 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.common.bottomsheet + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.util.lerp +import com.wire.android.ui.common.colorsScheme +import com.wire.android.ui.common.dimensions +import com.wire.android.ui.theme.WireTheme +import com.wire.android.util.PreviewMultipleThemes + +@Composable +fun WireDragHandle( + modifier: Modifier = Modifier, + progress: Float = 0f, // 0f: line, 1f: arrow + color: Color = colorsScheme().secondaryText +) { + val animatedProgress by animateFloatAsState(targetValue = progress) + val thickness = dimensions().spacing3x + val lineHalfWidth = dimensions().spacing48x / 2 + val arrowWingWidth = dimensions().spacing28x / 2 + val arrowWingHeight = dimensions().spacing8x + + Canvas( + modifier = modifier + .padding(dimensions().spacing6x) + .size(width = (lineHalfWidth * 2) + thickness, height = arrowWingHeight + thickness) + ) { + val centerX = size.width / 2 + val centerY = size.height / 2 + val capRadius = thickness.toPx() / 2 + val tipY = lerp(centerY, capRadius, animatedProgress) + val endY = lerp(centerY, size.height - capRadius, animatedProgress) + + fun drawWing(multiplier: Float) { + val endX = centerX + (lerp(lineHalfWidth.toPx(), arrowWingWidth.toPx(), animatedProgress) * multiplier) + drawLine( + color = color, + start = Offset(centerX, tipY), + end = Offset(endX, endY), + strokeWidth = thickness.toPx(), + cap = StrokeCap.Round, + ) + } + drawWing(-1f) // Left + drawWing(1f) // Right + } +} + +@PreviewMultipleThemes +@Composable +fun WireDragHandlePreview_Line() = WireTheme { + WireDragHandle(progress = 0f) +} + +@PreviewMultipleThemes +@Composable +fun WireDragHandlePreview_Arrow() = WireTheme { + WireDragHandle(progress = 1f) +} diff --git a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/bottomsheet/WireModalSheetLayout.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/bottomsheet/WireModalSheetLayout.kt index fd0aeb14068..3e1dacdca3a 100644 --- a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/bottomsheet/WireModalSheetLayout.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/bottomsheet/WireModalSheetLayout.kt @@ -49,7 +49,7 @@ fun WireModalSheetLayout( onBackPress: (() -> Unit) = { sheetState.hide() }, onDismissRequest: (() -> Unit) = sheetState::onDismissRequest, shouldDismissOnBackPress: Boolean = true, - dragHandle: @Composable (() -> Unit)? = { WireBottomSheetDefaults.WireDragHandle() }, + dragHandle: @Composable (() -> Unit)? = { WireDragHandle() }, sheetContent: @Composable ColumnScope.(T) -> Unit ) { (sheetState.currentValue as? WireSheetValue.Expanded)?.let { expandedValue -> diff --git a/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireDimensions.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireDimensions.kt index 70e14e01e91..50dddf1264e 100644 --- a/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireDimensions.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireDimensions.kt @@ -23,7 +23,6 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import io.github.esentsov.PackagePrivate -import kotlin.Float @Immutable data class WireDimensions( @@ -189,7 +188,6 @@ data class WireDimensions( val notificationBadgeHeight: Dp, val notificationBadgeRadius: Dp, // Wire ModalSheetLayout - val modalBottomSheetDividerWidth: Dp, val modalBottomSheetHeaderHorizontalPadding: Dp, val modalBottomSheetHeaderVerticalPadding: Dp, val modalBottomSheetNoHeaderVerticalPadding: Dp, @@ -371,7 +369,6 @@ private val DefaultPhonePortraitWireDimensions: WireDimensions = WireDimensions( corner100x = 100.dp, notificationBadgeHeight = 18.dp, notificationBadgeRadius = 6.dp, - modalBottomSheetDividerWidth = 48.dp, modalBottomSheetHeaderHorizontalPadding = 8.dp, modalBottomSheetHeaderVerticalPadding = 16.dp, modalBottomSheetNoHeaderVerticalPadding = 24.dp, diff --git a/features/sketch/src/main/java/com/wire/android/feature/sketch/DrawingToolPicker.kt b/features/sketch/src/main/java/com/wire/android/feature/sketch/DrawingToolPicker.kt index c5103af30ba..a5cd7ae0774 100644 --- a/features/sketch/src/main/java/com/wire/android/feature/sketch/DrawingToolPicker.kt +++ b/features/sketch/src/main/java/com/wire/android/feature/sketch/DrawingToolPicker.kt @@ -21,7 +21,6 @@ import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.PaddingValues @@ -37,7 +36,6 @@ import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.IconToggleButtonColors import androidx.compose.material3.OutlinedIconToggleButton @@ -49,6 +47,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import com.wire.android.feature.sketch.util.PreviewMultipleThemes import com.wire.android.ui.common.bottomsheet.MenuModalSheetHeader +import com.wire.android.ui.common.bottomsheet.WireDragHandle import com.wire.android.ui.common.bottomsheet.WireMenuModalSheetContent import com.wire.android.ui.common.bottomsheet.WireModalSheetLayout import com.wire.android.ui.common.bottomsheet.WireModalSheetState @@ -80,14 +79,7 @@ fun DrawingToolPicker( .background(colorsScheme().surface) .wrapContentSize() ) { - Box( - Modifier - .align(Alignment.CenterHorizontally) - .padding(vertical = dimensions().spacing12x) - .size(width = dimensions().modalBottomSheetDividerWidth, height = dimensions().spacing4x) - .background(color = colorsScheme().background, shape = RoundedCornerShape(size = dimensions().spacing2x)) - - ) + WireDragHandle(modifier = Modifier.align(Alignment.CenterHorizontally)) WireMenuModalSheetContent( header = MenuModalSheetHeader.Visible(title = stringResource(id = R.string.title_color_picker)), menuItems = listOf { From a2885bf691ee523f54c25993262a1c1abc7699f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Saleniuk?= Date: Fri, 24 Apr 2026 08:40:33 +0200 Subject: [PATCH 02/10] add scrim to CommonTopAppBar as well --- .../bottomsheet/WireBottomSheetScaffold.kt | 79 +++++++++++++++---- 1 file changed, 62 insertions(+), 17 deletions(-) diff --git a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/bottomsheet/WireBottomSheetScaffold.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/bottomsheet/WireBottomSheetScaffold.kt index cbcbf7d9411..63cd7937fe0 100644 --- a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/bottomsheet/WireBottomSheetScaffold.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/bottomsheet/WireBottomSheetScaffold.kt @@ -15,6 +15,8 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see http://www.gnu.org/licenses/. */ +@file:OptIn(ExperimentalMaterial3Api::class) + package com.wire.android.ui.common.bottomsheet import androidx.activity.compose.BackHandler @@ -25,33 +27,46 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.material3.BottomSheetDefaults import androidx.compose.material3.BottomSheetScaffold import androidx.compose.material3.BottomSheetScaffoldState import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SheetState import androidx.compose.material3.SheetValue import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.rememberBottomSheetScaffoldState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +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.graphics.Shape import androidx.compose.ui.graphics.isSpecified import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInWindow +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.onClick import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.traversalIndex import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupProperties import kotlinx.coroutines.launch +import kotlin.math.roundToInt import androidx.compose.ui.R as ComposeUiR -@OptIn(ExperimentalMaterial3Api::class) @Composable fun WireBottomSheetScaffold( sheetContent: @Composable ColumnScope.() -> Unit, @@ -73,17 +88,25 @@ fun WireBottomSheetScaffold( properties: WireBottomSheetScaffoldProperties = WireBottomSheetScaffoldProperties(), content: @Composable (PaddingValues) -> Unit ) { + var topInset by remember { mutableIntStateOf(0) } + val scrimAlpha by animateFloatAsState(if (scaffoldState.bottomSheetState.targetValue == SheetValue.Expanded) 1f else 0f) val scope = rememberCoroutineScope() val dismissSheet: () -> Unit = { scope.launch { - if (properties.dismissToPartiallyExpanded) { - scaffoldState.bottomSheetState.partialExpand() - } else { - scaffoldState.bottomSheetState.hide() + when (properties.dismissToPartiallyExpanded) { + true -> scaffoldState.bottomSheetState.partialExpand() + false -> scaffoldState.bottomSheetState.hide() } } } - Box(modifier = Modifier.navigationBarsPadding()) { + + Box( + modifier = Modifier + .navigationBarsPadding() + .onGloballyPositioned { coordinates -> + topInset = coordinates.positionInWindow().y.roundToInt() + } + ) { BottomSheetScaffold( sheetContent = sheetContent, modifier = modifier, @@ -102,9 +125,9 @@ fun WireBottomSheetScaffold( Box { topBar() Scrim( - properties = properties, + color = properties.scrimColor, + alpha = scrimAlpha, onDismissRequest = dismissSheet, - visible = scaffoldState.bottomSheetState.targetValue == SheetValue.Expanded, modifier = Modifier.matchParentSize() ) } @@ -116,9 +139,9 @@ fun WireBottomSheetScaffold( content = { paddingValues -> content(paddingValues) Scrim( - properties = properties, + color = properties.scrimColor, + alpha = scrimAlpha, onDismissRequest = dismissSheet, - visible = scaffoldState.bottomSheetState.targetValue == SheetValue.Expanded, modifier = Modifier.fillMaxSize() ) } @@ -127,6 +150,7 @@ fun WireBottomSheetScaffold( dismissSheet() } } + StatusBarScrim(color = properties.scrimColor, alpha = scrimAlpha, topInset = topInset) } data class WireBottomSheetScaffoldProperties( @@ -138,16 +162,15 @@ data class WireBottomSheetScaffoldProperties( @Composable private fun Scrim( - properties: WireBottomSheetScaffoldProperties, - onDismissRequest: () -> Unit, - visible: Boolean, modifier: Modifier = Modifier, + color: Color = Color.Unspecified, + alpha: Float = 0f, + onDismissRequest: (() -> Unit)? = null, ) { - val alpha by animateFloatAsState(targetValue = if (visible) 1f else 0f) val closeSheet = stringResource(ComposeUiR.string.close_sheet) Canvas( modifier.let { - if (visible && properties.shouldDismissOnClickOutside) { + if (alpha > 0f && onDismissRequest != null) { it .pointerInput(onDismissRequest) { detectTapGestures { onDismissRequest() } @@ -165,8 +188,30 @@ private fun Scrim( } } ) { - if (properties.scrimColor.isSpecified) { - drawRect(color = properties.scrimColor, alpha = alpha.coerceIn(0f, 1f)) + if (color.isSpecified) { + drawRect(color = color, alpha = alpha.coerceIn(0f, 1f)) } } } + +@Composable +private fun StatusBarScrim( + color: Color = Color.Unspecified, + alpha: Float = 0f, + topInset: Int = 0, +) { + Popup( + alignment = Alignment.TopCenter, + offset = IntOffset(x = 0, y = -topInset), + properties = PopupProperties(focusable = false, clippingEnabled = false) + ) { + Scrim( + color = color, + alpha = alpha, + onDismissRequest = null, // status bar scrim should not be clickable + modifier = Modifier + .fillMaxWidth() + .height(with(LocalDensity.current) { topInset.toDp() }) + ) + } +} From 75392d09f260eba06560e5d3f1425f8aaed95558 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Saleniuk?= Date: Fri, 24 Apr 2026 08:46:53 +0200 Subject: [PATCH 03/10] detekt --- .../android/ui/common/bottomsheet/WireBottomSheetScaffold.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/bottomsheet/WireBottomSheetScaffold.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/bottomsheet/WireBottomSheetScaffold.kt index 63cd7937fe0..d9e5743e627 100644 --- a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/bottomsheet/WireBottomSheetScaffold.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/bottomsheet/WireBottomSheetScaffold.kt @@ -34,7 +34,6 @@ import androidx.compose.material3.BottomSheetDefaults import androidx.compose.material3.BottomSheetScaffold import androidx.compose.material3.BottomSheetScaffoldState import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.SheetState import androidx.compose.material3.SheetValue import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState From 89b2a2424909577f051ebfb42f0ab7c0bc95b69f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Saleniuk?= Date: Fri, 24 Apr 2026 09:19:50 +0200 Subject: [PATCH 04/10] disable StatusBarScrim for previews as it breaks them --- .../bottomsheet/WireBottomSheetScaffold.kt | 29 ++++++++++--------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/bottomsheet/WireBottomSheetScaffold.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/bottomsheet/WireBottomSheetScaffold.kt index d9e5743e627..6f30fa86927 100644 --- a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/bottomsheet/WireBottomSheetScaffold.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/bottomsheet/WireBottomSheetScaffold.kt @@ -53,6 +53,7 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInWindow import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.onClick @@ -199,18 +200,20 @@ private fun StatusBarScrim( alpha: Float = 0f, topInset: Int = 0, ) { - Popup( - alignment = Alignment.TopCenter, - offset = IntOffset(x = 0, y = -topInset), - properties = PopupProperties(focusable = false, clippingEnabled = false) - ) { - Scrim( - color = color, - alpha = alpha, - onDismissRequest = null, // status bar scrim should not be clickable - modifier = Modifier - .fillMaxWidth() - .height(with(LocalDensity.current) { topInset.toDp() }) - ) + if (!LocalInspectionMode.current) { + Popup( + alignment = Alignment.TopCenter, + offset = IntOffset(x = 0, y = -topInset), + properties = PopupProperties(focusable = false, clippingEnabled = false) + ) { + Scrim( + color = color, + alpha = alpha, + onDismissRequest = null, // status bar scrim should not be clickable + modifier = Modifier + .fillMaxWidth() + .height(with(LocalDensity.current) { topInset.toDp() }) + ) + } } } From bae099d0a8a60644994849686e3f643fb3f79607 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Saleniuk?= Date: Mon, 27 Apr 2026 17:14:11 +0200 Subject: [PATCH 05/10] feat: list of call participants [WPB-1057] --- .../ui/calling/model/UICallParticipant.kt | 2 + .../ui/calling/ongoing/OngoingCallScreen.kt | 30 ++- .../participantslist/ParticipantItem.kt | 179 ++++++++++++++++++ .../participantslist/ParticipantList.kt | 55 ++++++ app/src/main/res/drawable/ic_screen_share.xml | 10 + app/src/main/res/values/strings.xml | 6 + 6 files changed, 278 insertions(+), 4 deletions(-) create mode 100644 app/src/main/kotlin/com/wire/android/ui/calling/ongoing/participantslist/ParticipantItem.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/calling/ongoing/participantslist/ParticipantList.kt create mode 100644 app/src/main/res/drawable/ic_screen_share.xml diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/model/UICallParticipant.kt b/app/src/main/kotlin/com/wire/android/ui/calling/model/UICallParticipant.kt index b7b3fcf2407..b4f8ec042cb 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/model/UICallParticipant.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/model/UICallParticipant.kt @@ -18,10 +18,12 @@ package com.wire.android.ui.calling.model +import androidx.compose.runtime.Stable import com.wire.android.model.ImageAsset import com.wire.android.ui.home.conversationslist.model.Membership import com.wire.kalium.logic.data.id.QualifiedID +@Stable data class UICallParticipant( val id: QualifiedID, val clientId: String, diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallScreen.kt b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallScreen.kt index 1466e50db74..3b9040b1130 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallScreen.kt @@ -23,21 +23,26 @@ package com.wire.android.ui.calling.ongoing import android.content.pm.PackageManager import android.view.View import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.BottomSheetDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SheetValue +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.rememberBottomSheetScaffoldState import androidx.compose.material3.rememberStandardBottomSheetState @@ -61,6 +66,8 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.max +import androidx.compose.ui.zIndex import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver @@ -92,6 +99,7 @@ import com.wire.android.ui.calling.ongoing.incallreactions.InCallReactionsState import com.wire.android.ui.calling.ongoing.incallreactions.PreviewInCallReactionState import com.wire.android.ui.calling.ongoing.incallreactions.drawInCallReactions import com.wire.android.ui.calling.ongoing.incallreactions.rememberInCallReactionsState +import com.wire.android.ui.calling.ongoing.participantslist.ParticipantList import com.wire.android.ui.calling.ongoing.participantsview.FloatingSelfUserTile import com.wire.android.ui.calling.ongoing.participantsview.VerticalCallingPager import com.wire.android.ui.calling.ongoing.toast.InCallToast @@ -109,6 +117,8 @@ import com.wire.android.ui.common.colorsScheme import com.wire.android.ui.common.dialogs.PermissionPermanentlyDeniedDialog import com.wire.android.ui.common.dimensions import com.wire.android.ui.common.progress.WireCircularProgressIndicator +import com.wire.android.ui.common.rememberTopBarElevationState +import com.wire.android.ui.common.rowitem.SectionHeader import com.wire.android.ui.common.topappbar.NavigationIconType import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar import com.wire.android.ui.common.visbility.rememberVisibilityState @@ -454,17 +464,29 @@ private fun OngoingCallContent( } ) BoxWithConstraints { + val navBarHeight = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() Column( modifier = Modifier .heightIn(max = with(LocalDensity.current) { (constraints.maxHeight - topBarHeight).toDp() }) + .padding(top = max(dimensions().spacing8x, navBarHeight)) .onGloballyPositioned { sheetExpandableHeight = it.size.height.toFloat() } + .background(colorsScheme().background) ) { - Box( - modifier = Modifier // TODO: replace with proper list of participants + val lazyListState = rememberLazyListState() + Surface( + shadowElevation = lazyListState.rememberTopBarElevationState().value, + color = MaterialTheme.wireColorScheme.background, + modifier = Modifier .fillMaxWidth() - .height(0.dp) + .zIndex(1f) // ensure the section header is above the participant items when scrolled + ) { + SectionHeader(name = stringResource(R.string.calling_details_participants_header, participants.size)) + } + ParticipantList( + lazyListState = lazyListState, + participants = participants, ) } } diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/participantslist/ParticipantItem.kt b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/participantslist/ParticipantItem.kt new file mode 100644 index 00000000000..23306c3f584 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/participantslist/ParticipantItem.kt @@ -0,0 +1,179 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.calling.ongoing.participantslist + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import com.wire.android.R +import com.wire.android.model.NameBasedAvatar +import com.wire.android.model.UserAvatarData +import com.wire.android.ui.calling.model.UICallParticipant +import com.wire.android.ui.calling.ongoing.buildPreviewParticipantsList +import com.wire.android.ui.common.MembershipQualifierLabel +import com.wire.android.ui.common.avatar.UserProfileAvatar +import com.wire.android.ui.common.colorsScheme +import com.wire.android.ui.common.dimensions +import com.wire.android.ui.common.rowitem.RowItemTemplate +import com.wire.android.ui.common.typography +import com.wire.android.ui.home.conversationslist.model.Membership +import com.wire.android.ui.theme.WireTheme +import com.wire.android.util.ui.PreviewMultipleThemes + +@Composable +fun ParticipantItem( + participant: UICallParticipant, + modifier: Modifier = Modifier, +) { + RowItemTemplate( + leadingIcon = { + UserProfileAvatar( + UserAvatarData(asset = participant.avatar, nameBasedAvatar = NameBasedAvatar(participant.name, participant.accentId)), + ) + }, + titleStartPadding = dimensions().spacing0x, + title = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(dimensions().spacing6x) + ) { + Text( + text = participant.name.orEmpty(), + style = typography().title02, + color = colorsScheme().onSurface, + ) + MembershipQualifierLabel(membership = participant.membership) + } + }, + actions = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(dimensions().spacing8x) + ) { + if (participant.isSharingScreen) { + ActionIcon( + icon = R.drawable.ic_screen_share, + contentDescription = R.string.content_description_calling_screen_share_on, + ) + } + if (participant.isCameraOn) { + ActionIcon( + icon = R.drawable.ic_camera_on, + contentDescription = R.string.content_description_calling_screen_share_on, + ) + } + if (participant.isMuted) { + ActionIcon( + icon = R.drawable.ic_microphone_off, + contentDescription = R.string.content_description_calling_microphone_off, + ) + } else { + ActionIcon( + icon = R.drawable.ic_microphone_on, + contentDescription = R.string.content_description_calling_microphone_on, + active = participant.isSpeaking, + ) + } + } + }, + modifier = modifier.padding(start = dimensions().spacing8x), + ) +} + +@Composable +private fun ActionIcon( + @DrawableRes icon: Int, + @StringRes contentDescription: Int, + modifier: Modifier = Modifier, + active: Boolean = false, +) { + Icon( + painter = painterResource(icon), + contentDescription = stringResource(contentDescription), + tint = if (active) colorsScheme().primary else colorsScheme().onSurface, + modifier = modifier + .let { + if (active) { + it + .border( + color = colorsScheme().primary, + width = dimensions().spacing1x, + shape = RoundedCornerShape(dimensions().spacing3x) + ) + .background( + color = colorsScheme().primaryVariant, + shape = RoundedCornerShape(dimensions().spacing3x) + ) + } else { + it + } + } + .padding(dimensions().spacing3x) + ) +} + +private +val previewParticipant = buildPreviewParticipantsList(1).first() + +@PreviewMultipleThemes +@Composable +fun PreviewParticipantItem_Muted() = WireTheme { + ParticipantItem(participant = previewParticipant.copy(isMuted = true)) +} + +@PreviewMultipleThemes +@Composable +fun PreviewParticipantItem_NotMuted() = WireTheme { + ParticipantItem(participant = previewParticipant.copy(isMuted = false)) +} + +@PreviewMultipleThemes +@Composable +fun PreviewParticipantItem_Speaking() = WireTheme { + ParticipantItem(participant = previewParticipant.copy(isMuted = false, isSpeaking = true)) +} + +@PreviewMultipleThemes +@Composable +fun PreviewParticipantItem_NotMutedWithCamera() = WireTheme { + ParticipantItem(participant = previewParticipant.copy(isMuted = false, isCameraOn = true)) +} + +@PreviewMultipleThemes +@Composable +fun PreviewParticipantItem_NotMutedWithScreenShare() = WireTheme { + ParticipantItem(participant = previewParticipant.copy(isMuted = false, isSharingScreen = true)) +} + +@PreviewMultipleThemes +@Composable +fun PreviewParticipantItem_MutedGuest() = WireTheme { + ParticipantItem(participant = previewParticipant.copy(isMuted = true, membership = Membership.Guest)) +} diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/participantslist/ParticipantList.kt b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/participantslist/ParticipantList.kt new file mode 100644 index 00000000000..7f839b06bf1 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/participantslist/ParticipantList.kt @@ -0,0 +1,55 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.calling.ongoing.participantslist + +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.wire.android.ui.calling.model.UICallParticipant +import com.wire.android.ui.calling.ongoing.buildPreviewParticipantsList +import com.wire.android.ui.theme.WireTheme +import com.wire.android.util.ui.PreviewMultipleThemes +import kotlinx.collections.immutable.PersistentList + +@Composable +fun ParticipantList( + participants: PersistentList, + modifier: Modifier = Modifier, + lazyListState: LazyListState = rememberLazyListState(), +) { + LazyColumn( + state = lazyListState, + modifier = modifier, + ) { + items( + items = participants, + key = { it.id.value + it.clientId }, + ) { participant -> + ParticipantItem(participant) + } + } +} + +@PreviewMultipleThemes +@Composable +fun PreviewParticipantList() = WireTheme { + ParticipantList(participants = buildPreviewParticipantsList(3)) +} diff --git a/app/src/main/res/drawable/ic_screen_share.xml b/app/src/main/res/drawable/ic_screen_share.xml new file mode 100644 index 00000000000..40f8d44f098 --- /dev/null +++ b/app/src/main/res/drawable/ic_screen_share.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0ad37001b66..d9334dd76ea 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -159,6 +159,11 @@ Turn camera off Turn speaker on Turn speaker off + Screen share on + Camera on + Microphone on + Microphone off + Active speaker Show in call reactions panel Hide in call reactions panel Open calling details @@ -1158,6 +1163,7 @@ Jitter %1$d ms Learn more about the quality details + PARTICIPANTS (%d) Return to call From 343af8f825b193a2df46631d252e54b9ccbfb032 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Saleniuk?= Date: Mon, 27 Apr 2026 17:39:14 +0200 Subject: [PATCH 06/10] detekt --- .../ui/calling/ongoing/participantslist/ParticipantItem.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/participantslist/ParticipantItem.kt b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/participantslist/ParticipantItem.kt index 23306c3f584..d7f44841e7e 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/participantslist/ParticipantItem.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/participantslist/ParticipantItem.kt @@ -139,8 +139,7 @@ private fun ActionIcon( ) } -private -val previewParticipant = buildPreviewParticipantsList(1).first() +private val previewParticipant = buildPreviewParticipantsList(1).first() @PreviewMultipleThemes @Composable From 50be055c9a159b1001215611c840014f4cf28d08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Saleniuk?= Date: Wed, 29 Apr 2026 15:57:38 +0200 Subject: [PATCH 07/10] set max width for the calling controls panel --- .../android/ui/calling/ongoing/OngoingCallScreen.kt | 10 +++++++--- .../kotlin/com/wire/android/ui/theme/WireDimensions.kt | 2 ++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallScreen.kt b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallScreen.kt index 1466e50db74..7fa7e1eed0d 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallScreen.kt @@ -34,6 +34,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn import androidx.compose.material3.BottomSheetDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme @@ -449,9 +450,12 @@ private fun OngoingCallContent( showInCallReactionsPanel = !showInCallReactionsPanel }, onCameraPermissionPermanentlyDenied = onCameraPermissionPermanentlyDenied, - modifier = Modifier.onGloballyPositioned { - sheetPeekHeight = it.positionInParent().y + it.size.height.toFloat() - } + modifier = Modifier + .align(Alignment.CenterHorizontally) + .widthIn(max = dimensions().callingControlPanelMaxWidth) + .onGloballyPositioned { + sheetPeekHeight = it.positionInParent().y + it.size.height.toFloat() + } ) BoxWithConstraints { Column( diff --git a/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireDimensions.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireDimensions.kt index da3dc470faa..05918c0eb47 100644 --- a/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireDimensions.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireDimensions.kt @@ -198,6 +198,7 @@ data class WireDimensions( val defaultSearchLazyColumnHeight: Dp, val groupButtonHeight: Dp, // Calling + val callingControlPanelMaxWidth: Dp, val defaultCallingControlsSize: Dp, val defaultCallingControlsHeight: Dp, val defaultCallingControlsWidth: Dp, @@ -380,6 +381,7 @@ private val DefaultPhonePortraitWireDimensions: WireDimensions = WireDimensions( systemMessageIconSize = 16.dp, systemMessageIconLargeSize = 18.dp, groupButtonHeight = 82.dp, + callingControlPanelMaxWidth = 480.dp, defaultCallingControlsSize = 56.dp, defaultCallingControlsHeight = 40.dp, defaultCallingControlsWidth = 56.dp, From c1c9a5baa4fd03f3ad0501187ed449eeacc11620 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Saleniuk?= Date: Thu, 30 Apr 2026 10:44:50 +0200 Subject: [PATCH 08/10] resolved comments --- .../ui/calling/ongoing/OngoingCallScreen.kt | 58 +++++++++-------- .../bottomsheet/WireBottomSheetScaffold.kt | 65 ++++++------------- 2 files changed, 49 insertions(+), 74 deletions(-) diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallScreen.kt b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallScreen.kt index 7fa7e1eed0d..17deddca7c7 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallScreen.kt @@ -35,7 +35,7 @@ import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn -import androidx.compose.material3.BottomSheetDefaults +import androidx.compose.material3.BottomSheetScaffoldState import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SheetValue @@ -49,6 +49,7 @@ import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -101,8 +102,8 @@ import com.wire.android.ui.common.ConversationVerificationIcons import com.wire.android.ui.common.HandleActions import com.wire.android.ui.common.banner.PreviewSecurityClassificationBannerState import com.wire.android.ui.common.banner.SecurityClassificationBannerForConversation +import com.wire.android.ui.common.bottomsheet.SheetScrimState import com.wire.android.ui.common.bottomsheet.WireBottomSheetScaffold -import com.wire.android.ui.common.bottomsheet.WireBottomSheetScaffoldProperties import com.wire.android.ui.common.bottomsheet.WireDragHandle import com.wire.android.ui.common.bottomsheet.WireSheetValue import com.wire.android.ui.common.bottomsheet.rememberWireModalSheetState @@ -133,6 +134,7 @@ import kotlinx.collections.immutable.PersistentList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch import java.util.Locale @Suppress("ParameterWrapping") @@ -149,6 +151,7 @@ fun OngoingCallScreen( creationCallback = { factory -> factory.create(conversationId = conversationId) } ) ) { + val scope = rememberCoroutineScope() val permissionPermanentlyDeniedDialogState = rememberVisibilityState() val inCallReactionsState = rememberInCallReactionsState() val callDetailsBottomSheetState = rememberWireModalSheetState() @@ -156,6 +159,13 @@ fun OngoingCallScreen( val isPiPAvailableOnThisDevice = activity.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) val shouldUsePiPMode = BuildConfig.PICTURE_IN_PICTURE_ENABLED && isPiPAvailableOnThisDevice var inPictureInPictureMode by remember { mutableStateOf(shouldUsePiPMode && activity.isInPictureInPictureMode) } + val sheetState = rememberStandardBottomSheetState( + initialValue = SheetValue.PartiallyExpanded, + confirmValueChange = { + false // TODO: to be enabled when the participants list is implemented and added to the bottom sheet + } + ) + val scaffoldState = rememberBottomSheetScaffoldState(bottomSheetState = sheetState) if (shouldUsePiPMode) { ObservePictureInPictureMode { inPictureInPictureMode = it } @@ -212,16 +222,20 @@ fun OngoingCallScreen( } BackHandler { - when { - ongoingCallViewModel.state.selectedParticipant != null -> ongoingCallViewModel.onSelectedParticipant(null) - shouldUsePiPMode -> (activity as OngoingCallActivity).enterPiPMode(conversationId, ongoingCallViewModel.currentUserId) - else -> activity.moveTaskToBack(true) - } + when { + scaffoldState.bottomSheetState.currentValue == SheetValue.Expanded -> scope.launch { + scaffoldState.bottomSheetState.partialExpand() + } + ongoingCallViewModel.state.selectedParticipant != null -> ongoingCallViewModel.onSelectedParticipant(null) + shouldUsePiPMode -> (activity as OngoingCallActivity).enterPiPMode(conversationId, ongoingCallViewModel.currentUserId) + else -> activity.moveTaskToBack(true) + } } OngoingCallContent( callState = sharedCallingViewModel.callState, inCallReactionsState = inCallReactionsState, + scaffoldState = scaffoldState, toggleSpeaker = sharedCallingViewModel::toggleSpeaker, toggleMute = sharedCallingViewModel::toggleMute, hangUpCall = sharedCallingViewModel::hangUpCall, @@ -358,6 +372,7 @@ private fun HandleSendingVideoFeed( private fun OngoingCallContent( callState: CallState, inCallReactionsState: InCallReactionsState, + scaffoldState: BottomSheetScaffoldState, toggleSpeaker: () -> Unit, toggleMute: () -> Unit, hangUpCall: () -> Unit, @@ -381,19 +396,10 @@ private fun OngoingCallContent( onToastClick: (toastKey: InCallToast.Key) -> Unit, inCallReactionsEnabled: Boolean = BuildConfig.CALL_REACTIONS_ENABLED, initialShowInCallReactionsPanel: Boolean = false, // for preview purposes - sheetInitialValue: SheetValue = SheetValue.PartiallyExpanded, // for preview purposes ) { + val scope = rememberCoroutineScope() var sheetPeekHeight by remember { mutableStateOf(0f) } - var sheetExpandableHeight by remember { mutableStateOf(0f) } var topBarHeight by remember { mutableStateOf(0f) } - val sheetState = rememberStandardBottomSheetState( - initialValue = sheetInitialValue, - confirmValueChange = { targetValue -> - // do not allow to expand the sheet if there is nothing more to show in the expanded state (height is 0) - !(targetValue == SheetValue.Expanded && sheetExpandableHeight <= 0f) - } - ) - val scaffoldState = rememberBottomSheetScaffoldState(bottomSheetState = sheetState) var showInCallReactionsPanel by remember { mutableStateOf(initialShowInCallReactionsPanel && inCallReactionsEnabled) } val emojiPickerState = rememberWireModalSheetState(skipPartiallyExpanded = false) val isConnecting = participants.isEmpty() @@ -420,18 +426,17 @@ private fun OngoingCallContent( } }, sheetDragHandle = { - WireDragHandle(progress = if (sheetExpandableHeight == 0f || sheetState.targetValue == SheetValue.Expanded) 0f else 1f) + WireDragHandle(progress = if (scaffoldState.bottomSheetState.targetValue == SheetValue.Expanded) 0f else 1f) }, sheetPeekHeight = with(LocalDensity.current) { sheetPeekHeight.toDp() }, scaffoldState = scaffoldState, sheetShadowElevation = dimensions().spacing0x, sheetMaxWidth = LocalConfiguration.current.screenWidthDp.dp, - properties = WireBottomSheetScaffoldProperties( - shouldDismissOnBackPress = true, - shouldDismissOnClickOutside = true, - dismissToPartiallyExpanded = true, - scrimColor = BottomSheetDefaults.ScrimColor - ), + sheetScrim = SheetScrimState.Visible { + scope.launch { + scaffoldState.bottomSheetState.partialExpand() + } + }, sheetContent = { if (!inPictureInPictureMode) { CallingControls( @@ -461,9 +466,6 @@ private fun OngoingCallContent( Column( modifier = Modifier .heightIn(max = with(LocalDensity.current) { (constraints.maxHeight - topBarHeight).toDp() }) - .onGloballyPositioned { - sheetExpandableHeight = it.size.height.toFloat() - } ) { Box( modifier = Modifier // TODO: replace with proper list of participants @@ -746,6 +748,7 @@ fun PreviewOngoingCallContent( proteusVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, ), inCallReactionsState = PreviewInCallReactionState, + scaffoldState = rememberBottomSheetScaffoldState(bottomSheetState = rememberStandardBottomSheetState(initialValue = sheetValue)), toggleSpeaker = {}, toggleMute = {}, hangUpCall = {}, @@ -769,7 +772,6 @@ fun PreviewOngoingCallContent( othersVideosDisabled = true, toasts = toasts, onToastClick = {}, - sheetInitialValue = sheetValue, ) } diff --git a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/bottomsheet/WireBottomSheetScaffold.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/bottomsheet/WireBottomSheetScaffold.kt index 6f30fa86927..4bacf8fbee4 100644 --- a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/bottomsheet/WireBottomSheetScaffold.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/bottomsheet/WireBottomSheetScaffold.kt @@ -19,7 +19,6 @@ package com.wire.android.ui.common.bottomsheet -import androidx.activity.compose.BackHandler import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.Canvas import androidx.compose.foundation.gestures.detectTapGestures @@ -39,16 +38,15 @@ import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.rememberBottomSheetScaffoldState import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope 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.graphics.Shape -import androidx.compose.ui.graphics.isSpecified import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInWindow @@ -63,7 +61,6 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupProperties -import kotlinx.coroutines.launch import kotlin.math.roundToInt import androidx.compose.ui.R as ComposeUiR @@ -85,20 +82,11 @@ fun WireBottomSheetScaffold( snackbarHost: @Composable (SnackbarHostState) -> Unit = { SnackbarHost(it) }, containerColor: Color = WireBottomSheetDefaults.WireContainerColor, contentColor: Color = WireBottomSheetDefaults.WireContentColor, - properties: WireBottomSheetScaffoldProperties = WireBottomSheetScaffoldProperties(), + sheetScrim: SheetScrimState = SheetScrimState.Hidden, content: @Composable (PaddingValues) -> Unit ) { var topInset by remember { mutableIntStateOf(0) } val scrimAlpha by animateFloatAsState(if (scaffoldState.bottomSheetState.targetValue == SheetValue.Expanded) 1f else 0f) - val scope = rememberCoroutineScope() - val dismissSheet: () -> Unit = { - scope.launch { - when (properties.dismissToPartiallyExpanded) { - true -> scaffoldState.bottomSheetState.partialExpand() - false -> scaffoldState.bottomSheetState.hide() - } - } - } Box( modifier = Modifier @@ -124,12 +112,9 @@ fun WireBottomSheetScaffold( { Box { topBar() - Scrim( - color = properties.scrimColor, - alpha = scrimAlpha, - onDismissRequest = dismissSheet, - modifier = Modifier.matchParentSize() - ) + if (sheetScrim is SheetScrimState.Visible) { + Scrim(alpha = scrimAlpha, onDismissRequest = sheetScrim.onScrimClicked, modifier = Modifier.matchParentSize()) + } } } }, @@ -138,35 +123,30 @@ fun WireBottomSheetScaffold( contentColor = contentColor, content = { paddingValues -> content(paddingValues) - Scrim( - color = properties.scrimColor, - alpha = scrimAlpha, - onDismissRequest = dismissSheet, - modifier = Modifier.fillMaxSize() - ) + if (sheetScrim is SheetScrimState.Visible) { + Scrim(alpha = scrimAlpha, onDismissRequest = sheetScrim.onScrimClicked, modifier = Modifier.fillMaxSize()) + } } ) - BackHandler(enabled = properties.shouldDismissOnBackPress && scaffoldState.bottomSheetState.currentValue == SheetValue.Expanded) { - dismissSheet() - } } - StatusBarScrim(color = properties.scrimColor, alpha = scrimAlpha, topInset = topInset) + if (sheetScrim is SheetScrimState.Visible) { + StatusBarScrim(alpha = scrimAlpha, topInset = topInset) + } } -data class WireBottomSheetScaffoldProperties( - val shouldDismissOnBackPress: Boolean = false, - val shouldDismissOnClickOutside: Boolean = false, - val dismissToPartiallyExpanded: Boolean = true, // otherwise it will dismiss to hidden state - val scrimColor: Color = Color.Unspecified, -) +@Stable +sealed interface SheetScrimState { + data object Hidden : SheetScrimState + data class Visible(val onScrimClicked: (() -> Unit)? = null) : SheetScrimState +} @Composable private fun Scrim( modifier: Modifier = Modifier, - color: Color = Color.Unspecified, alpha: Float = 0f, onDismissRequest: (() -> Unit)? = null, ) { + val color = BottomSheetDefaults.ScrimColor val closeSheet = stringResource(ComposeUiR.string.close_sheet) Canvas( modifier.let { @@ -188,18 +168,12 @@ private fun Scrim( } } ) { - if (color.isSpecified) { - drawRect(color = color, alpha = alpha.coerceIn(0f, 1f)) - } + drawRect(color = color, alpha = alpha.coerceIn(0f, 1f)) } } @Composable -private fun StatusBarScrim( - color: Color = Color.Unspecified, - alpha: Float = 0f, - topInset: Int = 0, -) { +private fun StatusBarScrim(alpha: Float = 0f, topInset: Int = 0) { if (!LocalInspectionMode.current) { Popup( alignment = Alignment.TopCenter, @@ -207,7 +181,6 @@ private fun StatusBarScrim( properties = PopupProperties(focusable = false, clippingEnabled = false) ) { Scrim( - color = color, alpha = alpha, onDismissRequest = null, // status bar scrim should not be clickable modifier = Modifier From eedea57e0a7ee69c53ec4f186c04e54cdb61bcf8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Saleniuk?= Date: Thu, 30 Apr 2026 12:40:58 +0200 Subject: [PATCH 09/10] detekt --- .../com/wire/android/ui/calling/ongoing/OngoingCallScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallScreen.kt b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallScreen.kt index e317c0bb05e..377512fe77e 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallScreen.kt @@ -147,7 +147,7 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import java.util.Locale -@Suppress("ParameterWrapping") +@Suppress("ParameterWrapping", "CyclomaticComplexMethod") @Composable fun OngoingCallScreen( conversationId: ConversationId, From a80a2adc0f128aa55973e408567b7cbd818f9929 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Saleniuk?= Date: Mon, 4 May 2026 10:15:02 +0200 Subject: [PATCH 10/10] change content description for camera --- .../ui/calling/ongoing/participantslist/ParticipantItem.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/participantslist/ParticipantItem.kt b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/participantslist/ParticipantItem.kt index d7f44841e7e..b6275bbc821 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/participantslist/ParticipantItem.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/participantslist/ParticipantItem.kt @@ -86,7 +86,7 @@ fun ParticipantItem( if (participant.isCameraOn) { ActionIcon( icon = R.drawable.ic_camera_on, - contentDescription = R.string.content_description_calling_screen_share_on, + contentDescription = R.string.content_description_calling_camera_on, ) } if (participant.isMuted) {