diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/KnockRequest.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/KnockRequest.kt index d9df9a5bc2..1014d13e58 100644 --- a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/KnockRequest.kt +++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/KnockRequest.kt @@ -29,3 +29,17 @@ fun KnockRequest.getAvatarData(size: AvatarSize) = AvatarData( fun KnockRequest.getBestName(): String { return displayName?.takeIf { it.isNotEmpty() } ?: userId.value } + +fun aKnockRequest( + userId: UserId = UserId("@jacob_ross:example.com"), + displayName: String? = "Jacob Ross", + avatarUrl: String? = null, + reason: String? = "Hi, I would like to get access to this room please.", + formattedDate: String = "20 Nov 2024", +) = KnockRequest( + userId = userId, + displayName = displayName, + avatarUrl = avatarUrl, + reason = reason, + formattedDate = formattedDate, +) diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerState.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerState.kt new file mode 100644 index 0000000000..3f993a1d40 --- /dev/null +++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerState.kt @@ -0,0 +1,60 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.features.knockrequests.impl.banner + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import io.element.android.features.knockrequests.impl.KnockRequest +import io.element.android.features.knockrequests.impl.getBestName +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.ui.strings.CommonPlurals +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.collections.immutable.ImmutableList + +@Immutable +sealed interface KnockRequestsBannerState { + data object Hidden : KnockRequestsBannerState + data class Visible( + val knockRequests: ImmutableList, + val acceptAction: AsyncAction, + val canAccept: Boolean, + ) : KnockRequestsBannerState { + + val subtitle = if (knockRequests.size == 1) { + knockRequests.first().userId.value + } else { + null + } + + val reason = if (knockRequests.size == 1) { + knockRequests.first().reason + } else { + null + } + + @Composable + fun formattedTitle(): String { + return when (knockRequests.size) { + 0 -> "" + 1 -> stringResource(CommonStrings.screen_room_single_knock_request_title, knockRequests.first().getBestName()) + else -> { + val firstRequest = knockRequests.first() + val otherRequestsCount = knockRequests.size - 1 + pluralStringResource( + id = CommonPlurals.screen_room_multiple_knock_requests_title, + count = otherRequestsCount, + firstRequest.getBestName(), + otherRequestsCount + ) + } + } + } + } +} diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerStateProvider.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerStateProvider.kt new file mode 100644 index 0000000000..330c919c6a --- /dev/null +++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerStateProvider.kt @@ -0,0 +1,55 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.features.knockrequests.impl.banner + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.knockrequests.impl.KnockRequest +import io.element.android.features.knockrequests.impl.aKnockRequest +import io.element.android.libraries.architecture.AsyncAction +import kotlinx.collections.immutable.toImmutableList + +class KnockRequestsBannerStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + KnockRequestsBannerState.Hidden, + aVisibleKnockRequestsBannerState(), + aVisibleKnockRequestsBannerState( + knockRequests = listOf( + aKnockRequest(), + aKnockRequest(displayName = "Alice") + ) + ), + aVisibleKnockRequestsBannerState( + knockRequests = listOf( + aKnockRequest(), + aKnockRequest(displayName = "Alice"), + aKnockRequest(displayName = "Bob"), + aKnockRequest(displayName = "Charlie") + ) + ), + aVisibleKnockRequestsBannerState( + canAccept = false + ), + aVisibleKnockRequestsBannerState( + acceptAction = AsyncAction.Loading + ), + aVisibleKnockRequestsBannerState( + acceptAction = AsyncAction.Failure(Throwable()) + ), + ) +} + +fun aVisibleKnockRequestsBannerState( + knockRequests: List = listOf(aKnockRequest()), + acceptAction: AsyncAction = AsyncAction.Uninitialized, + canAccept: Boolean = true, +) = KnockRequestsBannerState.Visible( + knockRequests = knockRequests.toImmutableList(), + acceptAction = acceptAction, + canAccept = canAccept +) diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerView.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerView.kt new file mode 100644 index 0000000000..a8fc985b7d --- /dev/null +++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerView.kt @@ -0,0 +1,204 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.features.knockrequests.impl.banner + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +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.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.knockrequests.impl.KnockRequest +import io.element.android.features.knockrequests.impl.getAvatarData +import io.element.android.libraries.designsystem.components.avatar.Avatar +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.designsystem.theme.components.ButtonSize +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.OutlinedButton +import io.element.android.libraries.designsystem.theme.components.Surface +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.collections.immutable.ImmutableList + +private const val MAX_AVATAR_COUNT = 3 + +@Composable +fun KnockRequestsBannerView( + state: KnockRequestsBannerState, + onDismissClick: () -> Unit, + onViewRequestsClick: () -> Unit, + modifier: Modifier = Modifier, +) { + when (state) { + is KnockRequestsBannerState.Hidden -> Unit + is KnockRequestsBannerState.Visible -> VisibleKnockRequestsBannerView( + state = state, + onDismissClick = onDismissClick, + onViewRequestsClick = onViewRequestsClick, + modifier = modifier + ) + } +} + +@Composable +private fun VisibleKnockRequestsBannerView( + state: KnockRequestsBannerState.Visible, + onDismissClick: () -> Unit, + onViewRequestsClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.small, + color = ElementTheme.colors.bgCanvasDefault, + shadowElevation = 24.dp + ) { + Column( + Modifier + .fillMaxWidth() + .padding(all = 16.dp) + ) { + Row { + KnockRequestAvatarView(state.knockRequests) + Spacer(modifier = Modifier.width(10.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = state.formattedTitle(), + style = ElementTheme.typography.fontBodyMdMedium, + color = MaterialTheme.colorScheme.primary, + textAlign = TextAlign.Start, + ) + if (state.subtitle != null) { + Text( + text = state.subtitle, + style = ElementTheme.typography.fontBodySmRegular, + color = MaterialTheme.colorScheme.secondary, + textAlign = TextAlign.Start, + ) + } + } + Icon( + modifier = Modifier.clickable(onClick = onDismissClick), + imageVector = CompoundIcons.Close(), + contentDescription = stringResource(CommonStrings.action_close) + ) + } + if (state.reason != null) { + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = state.reason, + color = ElementTheme.colors.textPrimary, + style = ElementTheme.typography.fontBodyMdRegular, + ) + } + Spacer(modifier = Modifier.height(16.dp)) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + if (state.knockRequests.size > 1) { + Button( + text = "View all", + onClick = onViewRequestsClick, + size = ButtonSize.MediumLowPadding, + modifier = Modifier.weight(1f), + ) + } else { + OutlinedButton( + text = "View", + onClick = onViewRequestsClick, + size = ButtonSize.MediumLowPadding, + modifier = Modifier.weight(1f), + ) + if (state.canAccept) { + Button( + text = "Accept", + onClick = {}, + size = ButtonSize.MediumLowPadding, + modifier = Modifier.weight(1f), + ) + } + } + } + } + } +} + +@Composable +private fun KnockRequestAvatarView( + knockRequests: ImmutableList, + modifier: Modifier = Modifier, +) { + Box(modifier) { + when (knockRequests.size) { + 0 -> Unit + 1 -> Avatar(knockRequests.first().getAvatarData(AvatarSize.KnockRequestBanner)) + else -> KnockRequestAvatarListView(knockRequests) + } + } +} + +@Composable +private fun KnockRequestAvatarListView( + knockRequests: ImmutableList, + modifier: Modifier = Modifier, +) { + val avatarSize = AvatarSize.KnockRequestBanner.dp + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(-avatarSize / 2), + ) { + knockRequests + .take(MAX_AVATAR_COUNT) + .forEachIndexed { index, knockRequest -> + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(size = avatarSize) + .clip(CircleShape) + .background(color = ElementTheme.colors.bgCanvasDefault) + .zIndex(-index.toFloat()), + ) { + Avatar( + modifier = Modifier.padding(2.dp), + avatarData = knockRequest.getAvatarData(AvatarSize.KnockRequestBanner), + ) + } + } + } +} + +@Composable +@PreviewsDayNight +internal fun KnockRequestsBannerViewPreview(@PreviewParameter(KnockRequestsBannerStateProvider::class) state: KnockRequestsBannerState) = ElementPreview { + KnockRequestsBannerView( + state = state, + onDismissClick = {}, + onViewRequestsClick = {}, + modifier = Modifier.padding(16.dp) + ) +} diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListStateProvider.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListStateProvider.kt index 551f6b89b0..476bf556e1 100644 --- a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListStateProvider.kt +++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListStateProvider.kt @@ -9,6 +9,7 @@ package io.element.android.features.knockrequests.impl.list import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.features.knockrequests.impl.KnockRequest +import io.element.android.features.knockrequests.impl.aKnockRequest import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.matrix.api.core.UserId @@ -101,20 +102,6 @@ open class KnockRequestsListStateProvider : PreviewParameterProvider> = AsyncData.Success(persistentListOf()), currentAction: KnockRequestsCurrentAction = KnockRequestsCurrentAction.None, diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt index c5bd468abe..3f7d087f41 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt @@ -56,4 +56,5 @@ enum class AvatarSize(val dp: Dp) { Suggestion(32.dp), KnockRequestItem(52.dp), + KnockRequestBanner(32.dp), }