knock requests : start implementing banner ui

This commit is contained in:
ganfra
2024-12-05 12:11:24 +01:00
parent 41ac352060
commit b149acff26
6 changed files with 335 additions and 14 deletions

View File

@@ -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,
)

View File

@@ -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<KnockRequest>,
val acceptAction: AsyncAction<Unit>,
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
)
}
}
}
}
}

View File

@@ -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<KnockRequestsBannerState> {
override val values: Sequence<KnockRequestsBannerState>
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<KnockRequest> = listOf(aKnockRequest()),
acceptAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
canAccept: Boolean = true,
) = KnockRequestsBannerState.Visible(
knockRequests = knockRequests.toImmutableList(),
acceptAction = acceptAction,
canAccept = canAccept
)

View File

@@ -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<KnockRequest>,
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<KnockRequest>,
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)
)
}

View File

@@ -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<KnockReques
)
}
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,
)
fun aKnockRequestsListState(
knockRequests: AsyncData<ImmutableList<KnockRequest>> = AsyncData.Success(persistentListOf()),
currentAction: KnockRequestsCurrentAction = KnockRequestsCurrentAction.None,

View File

@@ -56,4 +56,5 @@ enum class AvatarSize(val dp: Dp) {
Suggestion(32.dp),
KnockRequestItem(52.dp),
KnockRequestBanner(32.dp),
}