From e21691970925d73607aa6d8ac15ba8f0e39c7440 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Saleniuk?= Date: Fri, 27 Feb 2026 18:17:03 +0100 Subject: [PATCH] feat: call details button with low network state [WPB-23579] --- .../android/di/accountScoped/CallsModule.kt | 5 + .../ui/calling/ongoing/CallDetailsButton.kt | 112 +++++++++ .../ui/calling/ongoing/OngoingCallScreen.kt | 20 +- .../ui/calling/ongoing/OngoingCallState.kt | 11 +- .../calling/ongoing/OngoingCallViewModel.kt | 19 +- app/src/main/res/drawable/ic_network.xml | 26 +++ app/src/main/res/values/strings.xml | 2 + .../ui/calling/OngoingCallViewModelTest.kt | 40 +++- .../ui/common/OverlappingCirclesRow.kt | 218 ++++++++++++++++++ .../ui/common/avatar/UserProfileAvatarsRow.kt | 39 ++-- kalium | 2 +- 11 files changed, 463 insertions(+), 31 deletions(-) create mode 100644 app/src/main/kotlin/com/wire/android/ui/calling/ongoing/CallDetailsButton.kt create mode 100644 app/src/main/res/drawable/ic_network.xml create mode 100644 core/ui-common/src/main/kotlin/com/wire/android/ui/common/OverlappingCirclesRow.kt diff --git a/app/src/main/kotlin/com/wire/android/di/accountScoped/CallsModule.kt b/app/src/main/kotlin/com/wire/android/di/accountScoped/CallsModule.kt index d2dc8d46c52..e67aae06b17 100644 --- a/app/src/main/kotlin/com/wire/android/di/accountScoped/CallsModule.kt +++ b/app/src/main/kotlin/com/wire/android/di/accountScoped/CallsModule.kt @@ -213,4 +213,9 @@ class CallsModule { @Provides fun provideObserveInCallReactionsUseCase(callsScope: CallsScope) = callsScope.observeInCallReactions + + @ViewModelScoped + @Provides + fun provideObserveCallQualityDataUseCase(callsScope: CallsScope) = + callsScope.observeCallQualityData } diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/CallDetailsButton.kt b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/CallDetailsButton.kt new file mode 100644 index 00000000000..a5213178bc3 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/CallDetailsButton.kt @@ -0,0 +1,112 @@ +/* + * 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 + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandIn +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.shrinkOut +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.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import com.wire.android.R +import com.wire.android.ui.common.OverlapDirection +import com.wire.android.ui.common.OverlappingCirclesRow +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.ui.PreviewMultipleThemes +import com.wire.kalium.logic.data.call.CallQuality + +@Composable +fun CallDetailsButton(callQuality: CallQuality, modifier: Modifier = Modifier) { + IconButton( + modifier = modifier, + onClick = { /* TODO */ }, + content = { + OverlappingCirclesRow( + overlapSize = dimensions().spacing4x, + overlapCutoutSize = dimensions().spacing1x, + overlapDirection = OverlapDirection.StartOnTop, + items = listOf( + { LowNetworkItem(callQuality = callQuality) }, + { InfoItem() } + ) + ) + }, + ) +} + +@Composable +private fun LowNetworkItem(callQuality: CallQuality) { + AnimatedVisibility( + visible = callQuality.isLowQuality, + enter = fadeIn() + scaleIn() + expandIn(expandFrom = Alignment.Center), + exit = fadeOut() + scaleOut() + shrinkOut(shrinkTowards = Alignment.Center) + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(dimensions().wireIconButtonSize) + .clip(CircleShape) + .background(color = colorsScheme().warning, shape = CircleShape) + .padding(horizontal = dimensions().spacing2x, vertical = dimensions().spacing4x), + ) { + Icon( + painter = painterResource(id = R.drawable.ic_network), + contentDescription = stringResource(R.string.content_description_call_low_network_quality), + tint = colorsScheme().onWarning, + ) + } + } +} + +@Composable +private fun InfoItem() { + Icon( + painter = painterResource(id = R.drawable.ic_info), + contentDescription = stringResource(R.string.content_description_call_open_calling_details), + tint = colorsScheme().onBackground, + modifier = Modifier.size(dimensions().wireIconButtonSize) + ) +} + +@PreviewMultipleThemes +@Composable +fun CallDetailsButtonPreview() = WireTheme { + CallDetailsButton(callQuality = CallQuality.NORMAL) +} + +@PreviewMultipleThemes +@Composable +fun CallDetailsButtonWithLowNetworkPreview() = WireTheme { + CallDetailsButton(callQuality = CallQuality.POOR) +} 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 341b7cb739f..e0bb37d0eda 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 @@ -103,6 +103,7 @@ import com.wire.android.util.ui.PreviewMultipleThemes import com.wire.android.util.ui.PreviewMultipleThemesForLandscape import com.wire.android.util.ui.PreviewMultipleThemesForPortrait import com.wire.android.util.ui.PreviewMultipleThemesForSquare +import com.wire.kalium.logic.data.call.CallQuality import com.wire.kalium.logic.data.call.CallStatus import com.wire.kalium.logic.data.conversation.Conversation import com.wire.kalium.logic.data.id.ConversationId @@ -211,6 +212,7 @@ fun OngoingCallScreen( participants = sharedCallingViewModel.participantsState, inPictureInPictureMode = inPictureInPictureMode, recentReactions = sharedCallingViewModel.recentReactions, + callQuality = ongoingCallViewModel.state.callQualityData.quality ) ObserveRotation(sharedCallingViewModel::setUIRotation) @@ -324,6 +326,7 @@ private fun OngoingCallContent( participants: PersistentList, recentReactions: Map, inPictureInPictureMode: Boolean, + callQuality: CallQuality, initialShowInCallReactionsPanel: Boolean = false, // for preview purposes ) { var shouldOpenFullScreen by remember { mutableStateOf(false) } @@ -345,7 +348,8 @@ private fun OngoingCallContent( onCollapse = onCollapse, protocolInfo = callState.protocolInfo, mlsVerificationStatus = callState.mlsVerificationStatus, - proteusVerificationStatus = callState.proteusVerificationStatus + proteusVerificationStatus = callState.proteusVerificationStatus, + callQuality = callQuality ) } } @@ -507,6 +511,7 @@ private fun OngoingCallTopBar( protocolInfo: Conversation.ProtocolInfo?, mlsVerificationStatus: Conversation.VerificationStatus?, proteusVerificationStatus: Conversation.VerificationStatus?, + callQuality: CallQuality, onCollapse: () -> Unit ) { Column { @@ -534,7 +539,9 @@ private fun OngoingCallTopBar( }, navigationIconType = NavigationIconType.Collapse, elevation = 0.dp, - actions = {} + actions = { + CallDetailsButton(callQuality = callQuality) + } ) if (isCbrEnabled) { Text( @@ -644,6 +651,7 @@ fun PreviewOngoingCallContent(participants: PersistentList, i selectedParticipantForFullScreen = SelectedParticipant(), recentReactions = emptyMap(), initialShowInCallReactionsPanel = inCallReactionsPanelVisible, + callQuality = CallQuality.NORMAL ) } @@ -685,7 +693,13 @@ fun PreviewOngoingCallScreenConnecting() = WireTheme { @PreviewMultipleThemes @Composable fun PreviewOngoingCallTopBar() = WireTheme { - OngoingCallTopBar("Default", true, null, null, null) { } + OngoingCallTopBar("Default", true, null, null, null, CallQuality.NORMAL) { } +} + +@PreviewMultipleThemes +@Composable +fun PreviewOngoingCallTopBarWithPoorQuality() = WireTheme { + OngoingCallTopBar("Default", true, null, null, null, CallQuality.POOR) { } } fun buildPreviewParticipantsList(count: Int = 10) = buildList { diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallState.kt b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallState.kt index 8b53e8ed232..e5a7d1ee978 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallState.kt @@ -17,8 +17,17 @@ */ package com.wire.android.ui.calling.ongoing +import com.wire.kalium.logic.data.call.CallQuality +import com.wire.kalium.logic.data.call.CallQualityData + data class OngoingCallState( - val flowState: FlowState = FlowState.Default + val flowState: FlowState = FlowState.Default, + val callQualityData: CallQualityData = CallQualityData( + quality = CallQuality.UNKNOWN, + roundTripTimeInMilliseconds = -1, + upstreamPacketLossPercentage = -1, + downstreamPacketLossPercentage = -1, + ), ) { sealed interface FlowState { object Default : FlowState diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallViewModel.kt index 33843958d80..70ac6c242b4 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallViewModel.kt @@ -30,10 +30,11 @@ import com.wire.android.di.CurrentAccount import com.wire.android.ui.calling.model.UICallParticipant import com.wire.android.ui.calling.ongoing.fullscreen.SelectedParticipant import com.wire.kalium.logic.data.call.CallClient -import com.wire.kalium.logic.data.call.CallQuality +import com.wire.kalium.logic.data.call.CallResolutionQuality import com.wire.kalium.logic.data.call.VideoState import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.data.user.UserId +import com.wire.kalium.logic.feature.call.usecase.ObserveCallQualityDataUseCase import com.wire.kalium.logic.feature.call.usecase.ObserveLastActiveCallWithSortedParticipantsUseCase import com.wire.kalium.logic.feature.call.usecase.RequestVideoStreamsUseCase import com.wire.kalium.logic.feature.call.usecase.video.SetVideoSendStateUseCase @@ -58,6 +59,7 @@ class OngoingCallViewModel @AssistedInject constructor( private val observeLastActiveCall: ObserveLastActiveCallWithSortedParticipantsUseCase, private val requestVideoStreams: RequestVideoStreamsUseCase, private val setVideoSendState: SetVideoSendStateUseCase, + private val observeCallQualityData: ObserveCallQualityDataUseCase, ) : ViewModel() { var shouldShowDoubleTapToast: Boolean by mutableStateOf(false) private set @@ -70,6 +72,7 @@ class OngoingCallViewModel @AssistedInject constructor( init { observeCurrentCallFlowState() + observeCallQuality() showDoubleTapToast() } @@ -108,6 +111,14 @@ class OngoingCallViewModel @AssistedInject constructor( } } + fun observeCallQuality() { + viewModelScope.launch { + observeCallQualityData(conversationId).collectLatest { callQualityData -> + state = state.copy(callQualityData = callQualityData) + } + } + } + fun requestVideoStreams(participants: List) { viewModelScope.launch { participants @@ -129,11 +140,11 @@ class OngoingCallViewModel @AssistedInject constructor( } } - private fun mapQualityStream(uiParticipant: UICallParticipant): CallQuality { + private fun mapQualityStream(uiParticipant: UICallParticipant): CallResolutionQuality { return if (uiParticipant.clientId == selectedParticipant.clientId) { - CallQuality.HIGH + CallResolutionQuality.HIGH } else { - CallQuality.LOW + CallResolutionQuality.LOW } } diff --git a/app/src/main/res/drawable/ic_network.xml b/app/src/main/res/drawable/ic_network.xml new file mode 100644 index 00000000000..0fadd6a0d69 --- /dev/null +++ b/app/src/main/res/drawable/ic_network.xml @@ -0,0 +1,26 @@ + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0a7b8be4e7d..076d744c9ef 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -161,6 +161,8 @@ Turn speaker off Show in call reactions panel Hide in call reactions panel + Open calling details + Low network quality Reply to the message Cancel message reply Ping message diff --git a/app/src/test/kotlin/com/wire/android/ui/calling/OngoingCallViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/calling/OngoingCallViewModelTest.kt index f41928882cd..a92a24cd81b 100644 --- a/app/src/test/kotlin/com/wire/android/ui/calling/OngoingCallViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/calling/OngoingCallViewModelTest.kt @@ -28,12 +28,15 @@ import com.wire.android.ui.home.conversationslist.model.Membership import com.wire.kalium.logic.data.call.Call import com.wire.kalium.logic.data.call.CallClient import com.wire.kalium.logic.data.call.CallQuality +import com.wire.kalium.logic.data.call.CallQualityData +import com.wire.kalium.logic.data.call.CallResolutionQuality import com.wire.kalium.logic.data.call.CallStatus import com.wire.kalium.logic.data.call.VideoState 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.call.usecase.ObserveCallQualityDataUseCase import com.wire.kalium.logic.feature.call.usecase.ObserveLastActiveCallWithSortedParticipantsUseCase import com.wire.kalium.logic.feature.call.usecase.RequestVideoStreamsUseCase import com.wire.kalium.logic.feature.call.usecase.video.SetVideoSendStateUseCase @@ -42,6 +45,8 @@ import io.mockk.coEvery import io.mockk.coVerify import io.mockk.impl.annotations.MockK import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest @@ -190,8 +195,8 @@ class OngoingCallViewModelTest { fun givenParticipantsList_WhenRequestingVideoStreamForFullScreenParticipant_ThenRequestItInHighQuality() = runTest { val expectedClients = listOf( - CallClient(participant1.id.toString(), participant1.clientId, false, CallQuality.LOW), - CallClient(participant3.id.toString(), participant3.clientId, false, CallQuality.HIGH) + CallClient(participant1.id.toString(), participant1.clientId, false, CallResolutionQuality.LOW), + CallClient(participant3.id.toString(), participant3.clientId, false, CallResolutionQuality.HIGH) ) val (arrangement, ongoingCallViewModel) = Arrangement() @@ -216,8 +221,8 @@ class OngoingCallViewModelTest { fun givenParticipantsList_WhenRequestingVideoStreamForAllParticipant_ThenRequestItInLowQuality() = runTest { val expectedClients = listOf( - CallClient(participant1.id.toString(), participant1.clientId, false, CallQuality.LOW), - CallClient(participant3.id.toString(), participant3.clientId, false, CallQuality.LOW) + CallClient(participant1.id.toString(), participant1.clientId, false, CallResolutionQuality.LOW), + CallClient(participant3.id.toString(), participant3.clientId, false, CallResolutionQuality.LOW) ) val (arrangement, ongoingCallViewModel) = Arrangement() @@ -262,6 +267,25 @@ class OngoingCallViewModelTest { assertEquals(OngoingCallState.FlowState.CallClosed, ongoingCallViewModel.state.flowState) } + @Test + fun givenCallQualityChanges_WhenObservingQualityState_ThenStateIsUpdated() = runTest { + val initialQuality = CallQualityData(CallQuality.NORMAL, 100, 0, 0) + val callQualityFlow = MutableStateFlow(initialQuality) + val (_, ongoingCallViewModel) = Arrangement() + .withLastActiveCall(provideCall()) + .withShouldShowDoubleTapToastReturning(false) + .withSetVideoSendState() + .withCallQualityDataFlow(callQualityFlow) + .arrange() + advanceUntilIdle() + assertEquals(initialQuality, ongoingCallViewModel.state.callQualityData) + + val changedQuality = CallQualityData(CallQuality.POOR, 300, 10, 15) + callQualityFlow.value = changedQuality + advanceUntilIdle() + assertEquals(changedQuality, ongoingCallViewModel.state.callQualityData) + } + private class Arrangement { @MockK @@ -273,6 +297,9 @@ class OngoingCallViewModelTest { @MockK lateinit var setVideoSendState: SetVideoSendStateUseCase + @MockK + lateinit var observeCallQualityData: ObserveCallQualityDataUseCase + @MockK lateinit var globalDataStore: GlobalDataStore @@ -284,6 +311,7 @@ class OngoingCallViewModelTest { currentUserId = currentUserId, setVideoSendState = setVideoSendState, globalDataStore = globalDataStore, + observeCallQualityData = observeCallQualityData ) } @@ -322,6 +350,10 @@ class OngoingCallViewModelTest { ) } returns Unit } + + fun withCallQualityDataFlow(callQualityDataFlow: Flow) = apply { + coEvery { observeCallQualityData(any()) } returns callQualityDataFlow + } } companion object { diff --git a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/OverlappingCirclesRow.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/OverlappingCirclesRow.kt new file mode 100644 index 00000000000..ee425b9b82d --- /dev/null +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/OverlappingCirclesRow.kt @@ -0,0 +1,218 @@ +/* + * 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 + +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.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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.drawWithContent +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInParent +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.toSize +import com.wire.android.ui.theme.Accent +import com.wire.android.ui.theme.WireTheme +import com.wire.android.ui.theme.withAccent +import com.wire.android.util.PreviewMultipleThemes +import kotlin.math.max + +@Composable +fun OverlappingCirclesRow( + overlapSize: Dp, + overlapCutoutSize: Dp, + overlapDirection: OverlapDirection, + items: List<@Composable () -> Unit>, + modifier: Modifier = Modifier +) { + if (items.isEmpty()) return + Layout( + modifier = modifier, + content = { + var sizeAndPositionList by remember(items) { + mutableStateOf(List(items.size) { SizeAndPosition(Size.Zero, Offset.Zero) }) + } + items.forEachIndexed { index, item -> + Box( + modifier = Modifier + .clip(CircleShape) + .onGloballyPositioned { + sizeAndPositionList = sizeAndPositionList.toMutableList().also { list -> + list[index] = SizeAndPosition(size = it.size.toSize(), position = it.positionInParent()) + } + } + .drawWithContent { // this cuts out previous items in the overlap area if overlapCutoutBorderSize is set + with(drawContext.canvas.nativeCanvas) { + val checkPoint = saveLayer(null, null) + drawContent() // draw the original content first so that we can cut it out with the overlap area + if (overlapCutoutSize > 0.dp) { + // find all item indexes that overlap the current item based on the overlap direction + val overlappingIndexes = when (overlapDirection) { + OverlapDirection.StartOnTop -> 0 until index + OverlapDirection.EndOnTop -> (index + 1) until sizeAndPositionList.size + } + overlappingIndexes.forEach { overlappingIndex -> + val (overlapping, current) = sizeAndPositionList[overlappingIndex] to sizeAndPositionList[index] + // cut out the overlap area from the current item by drawing a rounded rect with BlendMode.Clear + drawRoundRect( + color = Color.Black, + cornerRadius = CornerRadius( + x = overlapping.size.minDimension, + y = overlapping.size.minDimension + ), + size = overlapping.size.withBorder(overlapCutoutSize.toPx()), + topLeft = overlapping.position.withBorder(overlapCutoutSize.toPx()).minus(current.position), + blendMode = BlendMode.Clear + ) + } + } + restoreToCount(checkPoint) + } + }, + content = { + item() + } + ) + } + }, + measurePolicy = { measurables, constraints -> + val placeables = measurables.map { it.measure(constraints) } + val height = placeables.maxOf { it.height } + val width = placeables.filter { it.width > 0 }.let { nonEmptyPlaceables -> + nonEmptyPlaceables.mapIndexed { index, item -> + when (index) { + nonEmptyPlaceables.lastIndex -> max(item.width, overlapSize.roundToPx()) + else -> (item.width - overlapSize.roundToPx()).coerceAtLeast(0) + } + } + }.sum() + layout(width, height) { + var x = 0 + for (i in placeables.indices) { + val y = (height - placeables[i].height) / 2 + val zIndex = if (overlapDirection == OverlapDirection.EndOnTop) i else placeables.size - i + placeables[i].placeRelative(x, y, zIndex.toFloat()) + x += (placeables[i].width - overlapSize.roundToPx()).coerceAtLeast(0) + } + } + } + ) +} + +enum class OverlapDirection { StartOnTop, EndOnTop } +private data class SizeAndPosition(val size: Size, val position: Offset) + +private fun Size.withBorder(borderWidth: Float) = if (isEmpty()) this else Size(width + (2 * borderWidth), height + (2 * borderWidth)) +private fun Offset.withBorder(borderWidth: Float) = Offset(x - borderWidth, y - borderWidth) + +@Composable +private fun OverlappingCirclesRowPreview( + overlapCutoutBorderSize: Dp, + overlapDirection: OverlapDirection, + layoutDirection: LayoutDirection, + count: Int = 6 +) = WireTheme { + CompositionLocalProvider(LocalLayoutDirection provides layoutDirection) { + OverlappingCirclesRow( + overlapSize = dimensions().spacing10x, + overlapCutoutSize = overlapCutoutBorderSize, + overlapDirection = overlapDirection, + items = List(count) { index -> + @Composable { + val accent = Accent.entries[index % Accent.entries.size] + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(dimensions().spacing32x) + .background(color = colorsScheme().withAccent(accent).primary, shape = CircleShape) + .padding(dimensions().spacing4x), + ) { + Icon( + painter = painterResource(id = R.drawable.ic_input_mandatory), + contentDescription = "", + tint = colorsScheme().withAccent(accent).onPrimary, + modifier = Modifier.size(dimensions().spacing16x) + ) + } + } + } + ) + } +} + +@PreviewMultipleThemes +@Composable +fun OverlappingCirclesRow_WithCutout_StartOnTop_LTR_Preview() = + OverlappingCirclesRowPreview(dimensions().spacing2x, OverlapDirection.StartOnTop, LayoutDirection.Ltr) + +@PreviewMultipleThemes +@Composable +fun OverlappingCirclesRow_WithCutout_StartOnTop_RTL_Preview() = + OverlappingCirclesRowPreview(dimensions().spacing2x, OverlapDirection.StartOnTop, LayoutDirection.Rtl) + +@PreviewMultipleThemes +@Composable +fun OverlappingCirclesRow_WithCutout_EndOnTop_LTR_Preview() = + OverlappingCirclesRowPreview(dimensions().spacing2x, OverlapDirection.EndOnTop, LayoutDirection.Ltr) + +@PreviewMultipleThemes +@Composable +fun OverlappingCirclesRow_WithCutout_EndOnTop_RTL_Preview() = + OverlappingCirclesRowPreview(dimensions().spacing2x, OverlapDirection.EndOnTop, LayoutDirection.Rtl) + +@PreviewMultipleThemes +@Composable +fun OverlappingCirclesRow_WithoutCutout_StartOnTop_LTR_Preview() = + OverlappingCirclesRowPreview(dimensions().spacing0x, OverlapDirection.StartOnTop, LayoutDirection.Ltr) + +@PreviewMultipleThemes +@Composable +fun OverlappingCirclesRow_WithoutCutout_StartOnTop_RTL_Preview() = + OverlappingCirclesRowPreview(dimensions().spacing0x, OverlapDirection.StartOnTop, LayoutDirection.Rtl) + +@PreviewMultipleThemes +@Composable +fun OverlappingCirclesRow_WithoutCutout_EndOnTop_LTR_Preview() = + OverlappingCirclesRowPreview(dimensions().spacing0x, OverlapDirection.EndOnTop, LayoutDirection.Ltr) + +@PreviewMultipleThemes +@Composable +fun OverlappingCirclesRow_WithoutCutout_EndOnTop_RTL_Preview() = + OverlappingCirclesRowPreview(dimensions().spacing0x, OverlapDirection.EndOnTop, LayoutDirection.Rtl) diff --git a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/avatar/UserProfileAvatarsRow.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/avatar/UserProfileAvatarsRow.kt index e747d331450..27b37794e3f 100644 --- a/core/ui-common/src/main/kotlin/com/wire/android/ui/common/avatar/UserProfileAvatarsRow.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/common/avatar/UserProfileAvatarsRow.kt @@ -18,8 +18,6 @@ package com.wire.android.ui.common.avatar import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.Surface import androidx.compose.runtime.Composable @@ -29,6 +27,8 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.Dp import com.wire.android.model.NameBasedAvatar import com.wire.android.model.UserAvatarData +import com.wire.android.ui.common.OverlapDirection +import com.wire.android.ui.common.OverlappingCirclesRow import com.wire.android.ui.common.colorsScheme import com.wire.android.ui.common.dimensions import com.wire.android.ui.theme.WireTheme @@ -43,24 +43,27 @@ fun UserProfileAvatarsRow( borderWidth: Dp = dimensions().spacing1x, borderColor: Color = colorsScheme().surfaceVariant, ) { - Row( + OverlappingCirclesRow( + overlapSize = overlapSize, + overlapCutoutSize = dimensions().spacing0x, + overlapDirection = OverlapDirection.EndOnTop, modifier = modifier, - horizontalArrangement = Arrangement.spacedBy(-overlapSize) - ) { - avatars.forEach { avatarData -> - UserProfileAvatar( - avatarData = avatarData, - size = avatarSize, - avatarBorderWidth = borderWidth, - avatarBorderColor = borderColor, - modifier = Modifier - .clip(CircleShape) - .background(borderColor), - padding = dimensions().spacing0x, - type = UserProfileAvatarType.WithoutIndicators, - ) + items = avatars.map { avatarData -> + { + UserProfileAvatar( + avatarData = avatarData, + size = avatarSize, + avatarBorderWidth = borderWidth, + avatarBorderColor = borderColor, + modifier = Modifier + .clip(CircleShape) + .background(borderColor), + padding = dimensions().spacing0x, + type = UserProfileAvatarType.WithoutIndicators, + ) + } } - } + ) } private val mockAvatars = listOf( diff --git a/kalium b/kalium index 83e3c7cbf31..910451b202d 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit 83e3c7cbf31e7996c03f91d9740b50e480acee35 +Subproject commit 910451b202d3d70f6612fa5bc742bfe61767361d