knock requests : refine and clean banner

This commit is contained in:
ganfra 2024-12-06 17:14:59 +01:00
parent 85669e6d10
commit ef3c056952
10 changed files with 205 additions and 157 deletions

View file

@ -17,16 +17,13 @@ import javax.inject.Inject
@ContributesBinding(RoomScope::class) @ContributesBinding(RoomScope::class)
class DefaultKnockRequestsBannerRenderer @Inject constructor( class DefaultKnockRequestsBannerRenderer @Inject constructor(
private val presenter: KnockRequestsBannerPresenter, private val presenter: KnockRequestsBannerPresenter,
): KnockRequestsBannerRenderer { ) : KnockRequestsBannerRenderer {
@Composable @Composable
override fun View(modifier: Modifier, onViewRequestsClick: () -> Unit) { override fun View(modifier: Modifier, onViewRequestsClick: () -> Unit) {
val state = presenter.present() val state = presenter.present()
KnockRequestsBannerView( KnockRequestsBannerView(
state = state, state = state,
onDismissClick = {},
onViewRequestsClick = onViewRequestsClick, onViewRequestsClick = onViewRequestsClick,
) )
} }
} }

View file

@ -0,0 +1,15 @@
/*
* 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 io.element.android.features.knockrequests.impl.KnockRequest
sealed interface KnockRequestsBannerEvents {
data class Accept(val knockRequest: KnockRequest) : KnockRequestsBannerEvents
data object Dismiss : KnockRequestsBannerEvents
}

View file

@ -8,12 +8,35 @@
package io.element.android.features.knockrequests.impl.banner package io.element.android.features.knockrequests.impl.banner
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import kotlinx.collections.immutable.persistentListOf
import javax.inject.Inject import javax.inject.Inject
class KnockRequestsBannerPresenter @Inject constructor(): Presenter<KnockRequestsBannerState> { class KnockRequestsBannerPresenter @Inject constructor() : Presenter<KnockRequestsBannerState> {
@Composable @Composable
override fun present(): KnockRequestsBannerState { override fun present(): KnockRequestsBannerState {
return KnockRequestsBannerState.Hidden var shouldShowBanner by remember { mutableStateOf(false) }
fun handleEvents(event: KnockRequestsBannerEvents) {
when (event) {
is KnockRequestsBannerEvents.Accept -> Unit
is KnockRequestsBannerEvents.Dismiss -> {
shouldShowBanner = false
}
}
}
return KnockRequestsBannerState(
knockRequests = persistentListOf(),
acceptAction = AsyncAction.Uninitialized,
canAccept = false,
isVisible = shouldShowBanner,
eventSink = ::handleEvents,
)
} }
} }

View file

@ -8,7 +8,6 @@
package io.element.android.features.knockrequests.impl.banner package io.element.android.features.knockrequests.impl.banner
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import io.element.android.features.knockrequests.impl.KnockRequest import io.element.android.features.knockrequests.impl.KnockRequest
@ -18,42 +17,39 @@ import io.element.android.libraries.ui.strings.CommonPlurals
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@Immutable data class KnockRequestsBannerState(
sealed interface KnockRequestsBannerState { val isVisible: Boolean,
data object Hidden : KnockRequestsBannerState val knockRequests: ImmutableList<KnockRequest>,
data class Visible( val acceptAction: AsyncAction<Unit>,
val knockRequests: ImmutableList<KnockRequest>, val canAccept: Boolean,
val acceptAction: AsyncAction<Unit>, val eventSink: (KnockRequestsBannerEvents) -> Unit,
val canAccept: Boolean, ) {
) : KnockRequestsBannerState { val subtitle = if (knockRequests.size == 1) {
knockRequests.first().userId.value
} else {
null
}
val subtitle = if (knockRequests.size == 1) { val reason = if (knockRequests.size == 1) {
knockRequests.first().userId.value knockRequests.first().reason
} else { } else {
null null
} }
val reason = if (knockRequests.size == 1) { @Composable
knockRequests.first().reason fun formattedTitle(): String {
} else { return when (knockRequests.size) {
null 0 -> ""
} 1 -> stringResource(CommonStrings.screen_room_single_knock_request_title, knockRequests.first().getBestName())
else -> {
@Composable val firstRequest = knockRequests.first()
fun formattedTitle(): String { val otherRequestsCount = knockRequests.size - 1
return when (knockRequests.size) { pluralStringResource(
0 -> "" id = CommonPlurals.screen_room_multiple_knock_requests_title,
1 -> stringResource(CommonStrings.screen_room_single_knock_request_title, knockRequests.first().getBestName()) count = otherRequestsCount,
else -> { firstRequest.getBestName(),
val firstRequest = knockRequests.first() otherRequestsCount
val otherRequestsCount = knockRequests.size - 1 )
pluralStringResource(
id = CommonPlurals.screen_room_multiple_knock_requests_title,
count = otherRequestsCount,
firstRequest.getBestName(),
otherRequestsCount
)
}
} }
} }
} }

View file

@ -16,15 +16,23 @@ import kotlinx.collections.immutable.toImmutableList
class KnockRequestsBannerStateProvider : PreviewParameterProvider<KnockRequestsBannerState> { class KnockRequestsBannerStateProvider : PreviewParameterProvider<KnockRequestsBannerState> {
override val values: Sequence<KnockRequestsBannerState> override val values: Sequence<KnockRequestsBannerState>
get() = sequenceOf( get() = sequenceOf(
KnockRequestsBannerState.Hidden, aKnockRequestsBannerState(),
aVisibleKnockRequestsBannerState(), aKnockRequestsBannerState(
aVisibleKnockRequestsBannerState( knockRequests = listOf(
aKnockRequest(
reason = "A very long reason that should probably be truncated, " +
"but could be also expanded so you can see it over the lines, wow," +
"very amazing reason, I know, right, I'm so good at writing reasons."
)
)
),
aKnockRequestsBannerState(
knockRequests = listOf( knockRequests = listOf(
aKnockRequest(), aKnockRequest(),
aKnockRequest(displayName = "Alice") aKnockRequest(displayName = "Alice")
) )
), ),
aVisibleKnockRequestsBannerState( aKnockRequestsBannerState(
knockRequests = listOf( knockRequests = listOf(
aKnockRequest(), aKnockRequest(),
aKnockRequest(displayName = "Alice"), aKnockRequest(displayName = "Alice"),
@ -32,24 +40,28 @@ class KnockRequestsBannerStateProvider : PreviewParameterProvider<KnockRequestsB
aKnockRequest(displayName = "Charlie") aKnockRequest(displayName = "Charlie")
) )
), ),
aVisibleKnockRequestsBannerState( aKnockRequestsBannerState(
canAccept = false canAccept = false
), ),
aVisibleKnockRequestsBannerState( aKnockRequestsBannerState(
acceptAction = AsyncAction.Loading acceptAction = AsyncAction.Loading
), ),
aVisibleKnockRequestsBannerState( aKnockRequestsBannerState(
acceptAction = AsyncAction.Failure(Throwable()) acceptAction = AsyncAction.Failure(Throwable("Failed to accept knock"))
), ),
) )
} }
fun aVisibleKnockRequestsBannerState( fun aKnockRequestsBannerState(
knockRequests: List<KnockRequest> = listOf(aKnockRequest()), knockRequests: List<KnockRequest> = listOf(aKnockRequest()),
acceptAction: AsyncAction<Unit> = AsyncAction.Uninitialized, acceptAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
canAccept: Boolean = true, canAccept: Boolean = true,
) = KnockRequestsBannerState.Visible( isVisible: Boolean = true,
eventSink: (KnockRequestsBannerEvents) -> Unit = {}
) = KnockRequestsBannerState(
knockRequests = knockRequests.toImmutableList(), knockRequests = knockRequests.toImmutableList(),
acceptAction = acceptAction, acceptAction = acceptAction,
canAccept = canAccept canAccept = canAccept,
isVisible = isVisible,
eventSink = eventSink,
) )

View file

@ -7,6 +7,9 @@
package io.element.android.features.knockrequests.impl.banner package io.element.android.features.knockrequests.impl.banner
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@ -27,6 +30,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
@ -52,96 +56,102 @@ private const val MAX_AVATAR_COUNT = 3
@Composable @Composable
fun KnockRequestsBannerView( fun KnockRequestsBannerView(
state: KnockRequestsBannerState, state: KnockRequestsBannerState,
onDismissClick: () -> Unit,
onViewRequestsClick: () -> Unit, onViewRequestsClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
when (state) { AnimatedVisibility(
is KnockRequestsBannerState.Hidden -> Unit visible = state.isVisible,
is KnockRequestsBannerState.Visible -> VisibleKnockRequestsBannerView( enter = expandVertically(),
state = state, exit = shrinkVertically(),
onDismissClick = onDismissClick, modifier = modifier,
onViewRequestsClick = onViewRequestsClick, ) {
modifier = modifier Surface(
) shape = MaterialTheme.shapes.small,
color = ElementTheme.colors.bgCanvasDefaultLevel1,
shadowElevation = 24.dp,
modifier = Modifier.padding(16.dp),
) {
KnockRequestsBannerContent(
state = state,
onViewRequestsClick = onViewRequestsClick,
)
}
} }
} }
@Composable @Composable
private fun VisibleKnockRequestsBannerView( private fun KnockRequestsBannerContent(
state: KnockRequestsBannerState.Visible, state: KnockRequestsBannerState,
onDismissClick: () -> Unit,
onViewRequestsClick: () -> Unit, onViewRequestsClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Surface( fun onDismissClick() {
modifier.fillMaxWidth(), state.eventSink(KnockRequestsBannerEvents.Dismiss)
shape = MaterialTheme.shapes.small, }
color = ElementTheme.colors.bgCanvasDefaultLevel1,
shadowElevation = 24.dp Column(
modifier
.fillMaxWidth()
.padding(all = 16.dp)
) { ) {
Column( Row {
Modifier KnockRequestAvatarView(state.knockRequests)
.fillMaxWidth() Spacer(modifier = Modifier.width(10.dp))
.padding(all = 16.dp) Column(modifier = Modifier.weight(1f)) {
) { Text(
Row { text = state.formattedTitle(),
KnockRequestAvatarView(state.knockRequests) style = ElementTheme.typography.fontBodyMdMedium,
Spacer(modifier = Modifier.width(10.dp)) color = MaterialTheme.colorScheme.primary,
Column(modifier = Modifier.weight(1f)) { textAlign = TextAlign.Start,
)
if (state.subtitle != null) {
Text( Text(
text = state.formattedTitle(), text = state.subtitle,
style = ElementTheme.typography.fontBodyMdMedium, style = ElementTheme.typography.fontBodySmRegular,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.secondary,
textAlign = TextAlign.Start, 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,
)
} }
Icon(
modifier = Modifier.clickable(onClick = ::onDismissClick),
imageVector = CompoundIcons.Close(),
contentDescription = stringResource(CommonStrings.action_close)
)
}
if (state.reason != null) {
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { Text(
if (state.knockRequests.size > 1) { text = state.reason,
color = ElementTheme.colors.textPrimary,
style = ElementTheme.typography.fontBodyMdRegular,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
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( Button(
text = "View all", text = "Accept",
onClick = onViewRequestsClick, onClick = {},
size = ButtonSize.MediumLowPadding, size = ButtonSize.MediumLowPadding,
modifier = Modifier.weight(1f), 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),
)
}
} }
} }
} }
@ -178,11 +188,11 @@ private fun KnockRequestAvatarListView(
Box( Box(
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
modifier = Modifier modifier = Modifier
.size(size = avatarSize) .size(size = avatarSize)
.clip(CircleShape) .clip(CircleShape)
.background(color = ElementTheme.colors.bgCanvasDefaultLevel1) .background(color = ElementTheme.colors.bgCanvasDefaultLevel1)
.zIndex(-index.toFloat()), .zIndex(-index.toFloat()),
) { ) {
Avatar( Avatar(
modifier = Modifier.padding(2.dp), modifier = Modifier.padding(2.dp),
avatarData = knockRequest.getAvatarData(AvatarSize.KnockRequestBanner), avatarData = knockRequest.getAvatarData(AvatarSize.KnockRequestBanner),
@ -197,8 +207,6 @@ private fun KnockRequestAvatarListView(
internal fun KnockRequestsBannerViewPreview(@PreviewParameter(KnockRequestsBannerStateProvider::class) state: KnockRequestsBannerState) = ElementPreview { internal fun KnockRequestsBannerViewPreview(@PreviewParameter(KnockRequestsBannerStateProvider::class) state: KnockRequestsBannerState) = ElementPreview {
KnockRequestsBannerView( KnockRequestsBannerView(
state = state, state = state,
onDismissClick = {},
onViewRequestsClick = {}, onViewRequestsClick = {},
modifier = Modifier.padding(16.dp)
) )
} }

View file

@ -238,9 +238,9 @@ class MessagesNode @AssistedInject constructor(
onCreatePollClick = this::onCreatePollClick, onCreatePollClick = this::onCreatePollClick,
onJoinCallClick = this::onJoinCallClick, onJoinCallClick = this::onJoinCallClick,
onViewAllPinnedMessagesClick = this::onViewAllPinnedMessagesClick, onViewAllPinnedMessagesClick = this::onViewAllPinnedMessagesClick,
knockRequestsBanner = { modifier -> knockRequestsBannerView = {
knockRequestsBannerRenderer.View( knockRequestsBannerRenderer.View(
modifier = modifier, modifier = Modifier,
onViewRequestsClick = this::onViewKnockRequestsClick onViewRequestsClick = this::onViewKnockRequestsClick
) )
}, },

View file

@ -118,7 +118,7 @@ fun MessagesView(
onViewAllPinnedMessagesClick: () -> Unit, onViewAllPinnedMessagesClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
forceJumpToBottomVisibility: Boolean = false, forceJumpToBottomVisibility: Boolean = false,
knockRequestsBanner: @Composable (Modifier) -> Unit, knockRequestsBannerView: @Composable () -> Unit,
) { ) {
OnLifecycleEvent { _, event -> OnLifecycleEvent { _, event ->
state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvents.LifecycleEvent(event)) state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvents.LifecycleEvent(event))
@ -196,8 +196,8 @@ fun MessagesView(
MessagesViewContent( MessagesViewContent(
state = state, state = state,
modifier = Modifier modifier = Modifier
.padding(padding) .padding(padding)
.consumeWindowInsets(padding), .consumeWindowInsets(padding),
onContentClick = ::onContentClick, onContentClick = ::onContentClick,
onMessageLongClick = ::onMessageLongClick, onMessageLongClick = ::onMessageLongClick,
onUserDataClick = { hidingKeyboard { onUserDataClick(it) } }, onUserDataClick = { hidingKeyboard { onUserDataClick(it) } },
@ -216,7 +216,7 @@ fun MessagesView(
forceJumpToBottomVisibility = forceJumpToBottomVisibility, forceJumpToBottomVisibility = forceJumpToBottomVisibility,
onJoinCallClick = onJoinCallClick, onJoinCallClick = onJoinCallClick,
onViewAllPinnedMessagesClick = onViewAllPinnedMessagesClick, onViewAllPinnedMessagesClick = onViewAllPinnedMessagesClick,
knockRequestsBanner = knockRequestsBanner, knockRequestsBannerView = knockRequestsBannerView,
) )
}, },
snackbarHost = { snackbarHost = {
@ -286,13 +286,13 @@ private fun MessagesViewContent(
forceJumpToBottomVisibility: Boolean, forceJumpToBottomVisibility: Boolean,
onSwipeToReply: (TimelineItem.Event) -> Unit, onSwipeToReply: (TimelineItem.Event) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
knockRequestsBanner: @Composable (Modifier) -> Unit, knockRequestsBannerView: @Composable () -> Unit,
) { ) {
Box( Box(
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
.navigationBarsPadding() .navigationBarsPadding()
.imePadding(), .imePadding(),
) { ) {
AttachmentsBottomSheet( AttachmentsBottomSheet(
state = state.composerState, state = state.composerState,
@ -375,9 +375,7 @@ private fun MessagesViewContent(
onViewAllClick = onViewAllPinnedMessagesClick, onViewAllClick = onViewAllPinnedMessagesClick,
) )
} }
Box(modifier = Modifier.padding(all = 16.dp)) { knockRequestsBannerView()
knockRequestsBanner(Modifier)
}
} }
}, },
sheetContent = { subcomposing: Boolean -> sheetContent = { subcomposing: Boolean ->
@ -404,13 +402,13 @@ private fun MessagesViewComposerBottomSheetContents(
Column(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.fillMaxWidth()) {
SuggestionsPickerView( SuggestionsPickerView(
modifier = Modifier modifier = Modifier
.heightIn(max = 230.dp) .heightIn(max = 230.dp)
// Consume all scrolling, preventing the bottom sheet from being dragged when interacting with the list of suggestions // Consume all scrolling, preventing the bottom sheet from being dragged when interacting with the list of suggestions
.nestedScroll(object : NestedScrollConnection { .nestedScroll(object : NestedScrollConnection {
override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset { override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {
return available return available
} }
}), }),
roomId = state.roomId, roomId = state.roomId,
roomName = state.roomName.dataOrNull(), roomName = state.roomName.dataOrNull(),
roomAvatarData = state.roomAvatar.dataOrNull(), roomAvatarData = state.roomAvatar.dataOrNull(),
@ -458,8 +456,8 @@ private fun MessagesViewTopBar(
title = { title = {
val roundedCornerShape = RoundedCornerShape(8.dp) val roundedCornerShape = RoundedCornerShape(8.dp)
val titleModifier = Modifier val titleModifier = Modifier
.clip(roundedCornerShape) .clip(roundedCornerShape)
.clickable { onRoomDetailsClick() } .clickable { onRoomDetailsClick() }
if (roomName != null && roomAvatar != null) { if (roomName != null && roomAvatar != null) {
RoomAvatarAndNameRow( RoomAvatarAndNameRow(
roomName = roomName, roomName = roomName,
@ -514,9 +512,9 @@ private fun RoomAvatarAndNameRow(
private fun CantSendMessageBanner() { private fun CantSendMessageBanner() {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.background(MaterialTheme.colorScheme.secondary) .background(MaterialTheme.colorScheme.secondary)
.padding(16.dp), .padding(16.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center horizontalArrangement = Arrangement.Center
) { ) {
@ -545,6 +543,6 @@ internal fun MessagesViewPreview(@PreviewParameter(MessagesStateProvider::class)
onJoinCallClick = {}, onJoinCallClick = {},
onViewAllPinnedMessagesClick = { }, onViewAllPinnedMessagesClick = { },
forceJumpToBottomVisibility = true, forceJumpToBottomVisibility = true,
knockRequestsBanner = {}, knockRequestsBannerView = {},
) )
} }

View file

@ -40,7 +40,6 @@ internal fun MessagesViewWithIdentityChangePreview(
onCreatePollClick = {}, onCreatePollClick = {},
onJoinCallClick = {}, onJoinCallClick = {},
onViewAllPinnedMessagesClick = {}, onViewAllPinnedMessagesClick = {},
knockRequestsBanner = {} knockRequestsBannerView = {}
) )
} }

View file

@ -533,7 +533,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setMessa
onCreatePollClick = onCreatePollClick, onCreatePollClick = onCreatePollClick,
onJoinCallClick = onJoinCallClick, onJoinCallClick = onJoinCallClick,
onViewAllPinnedMessagesClick = onViewAllPinnedMessagesClick, onViewAllPinnedMessagesClick = onViewAllPinnedMessagesClick,
knockRequestsBanner = {} knockRequestsBannerView = {}
) )
} }
} }