diff --git a/features/knockrequests/impl/build.gradle.kts b/features/knockrequests/impl/build.gradle.kts index 35d18b0c3a..83f7132320 100644 --- a/features/knockrequests/impl/build.gradle.kts +++ b/features/knockrequests/impl/build.gradle.kts @@ -24,6 +24,7 @@ dependencies { implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrixui) + implementation(projects.libraries.uiStrings) implementation(projects.libraries.designsystem) testImplementation(libs.test.junit) 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 new file mode 100644 index 0000000000..3a293dc565 --- /dev/null +++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/KnockRequest.kt @@ -0,0 +1,34 @@ +/* + * 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 + +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.user.MatrixUser + +data class KnockRequest( + val userId: UserId, + val displayName: String?, + val avatarUrl: String?, + val reason: String?, +) + +fun KnockRequest.getAvatarData(size: AvatarSize) = AvatarData( + id = userId.value, + name = displayName, + url = avatarUrl, + size = size, +) + +fun KnockRequest.getBestName(): String { + return displayName?.takeIf { it.isNotEmpty() } ?: userId.value +} + + + diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListEvents.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListEvents.kt index f7f1755f32..d84e5209b9 100644 --- a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListEvents.kt +++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListEvents.kt @@ -7,6 +7,11 @@ package io.element.android.features.knockrequests.impl.list +import io.element.android.features.knockrequests.impl.KnockRequest + sealed interface KnockRequestsListEvents { + data class Accept(val knockRequest: KnockRequest) : KnockRequestsListEvents + data class Decline(val knockRequest: KnockRequest) : KnockRequestsListEvents data object AcceptAll : KnockRequestsListEvents + data object DismissCurrentAction : KnockRequestsListEvents } diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListNode.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListNode.kt index 953b5991db..0310f67d2e 100644 --- a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListNode.kt +++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListNode.kt @@ -30,6 +30,7 @@ class KnockRequestsListNode @AssistedInject constructor( val state = presenter.present() KnockRequestsListView( state = state, + onBackClick = ::navigateUp, modifier = modifier ) } diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListPresenter.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListPresenter.kt index b290a5b99f..e7168e967b 100644 --- a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListPresenter.kt +++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListPresenter.kt @@ -8,21 +8,35 @@ package io.element.android.features.knockrequests.impl.list import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter +import kotlinx.collections.immutable.persistentListOf import javax.inject.Inject class KnockRequestsListPresenter @Inject constructor() : Presenter { @Composable override fun present(): KnockRequestsListState { + val actions = remember { + mutableStateOf(KnockRequestsCurrentAction.None) + } fun handleEvents(event: KnockRequestsListEvents) { when (event) { KnockRequestsListEvents.AcceptAll -> Unit + is KnockRequestsListEvents.Accept -> Unit + is KnockRequestsListEvents.Decline -> Unit + KnockRequestsListEvents.DismissCurrentAction -> { + actions.value = KnockRequestsCurrentAction.None + } } } return KnockRequestsListState( + knockRequests = AsyncData.Success(persistentListOf()), + currentAction = actions.value, eventSink = ::handleEvents ) } diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListState.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListState.kt index 8433c29a5a..0aa96b4f28 100644 --- a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListState.kt +++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListState.kt @@ -7,8 +7,20 @@ package io.element.android.features.knockrequests.impl.list -// TODO add your ui models. Remove the eventSink if you don't have events. -// Do not use default value, so no member get forgotten in the presenters. +import io.element.android.features.knockrequests.impl.KnockRequest +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.AsyncData +import kotlinx.collections.immutable.ImmutableList + data class KnockRequestsListState( - val eventSink: (KnockRequestsListEvents) -> Unit + val knockRequests: AsyncData>, + val currentAction: KnockRequestsCurrentAction, + val eventSink: (KnockRequestsListEvents) -> Unit, ) + +sealed interface KnockRequestsCurrentAction { + data object None : KnockRequestsCurrentAction + data class Accept(val async: AsyncAction) : KnockRequestsCurrentAction + data class Decline(val async: AsyncAction) : KnockRequestsCurrentAction + data class AcceptAll(val async: AsyncAction) : KnockRequestsCurrentAction +} 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 4301ccdc32..761c850180 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 @@ -8,15 +8,73 @@ 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.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.core.UserId +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf open class KnockRequestsListStateProvider : PreviewParameterProvider { override val values: Sequence get() = sequenceOf( - aKnockRequestsListState(), - // Add other states here + aKnockRequestsListState( + knockRequests = AsyncData.Loading(), + ), + aKnockRequestsListState( + knockRequests = AsyncData.Success( + persistentListOf() + ), + ), + aKnockRequestsListState( + knockRequests = AsyncData.Success( + persistentListOf( + aKnockRequest() + ) + ), + ), + aKnockRequestsListState( + knockRequests = AsyncData.Success( + persistentListOf( + aKnockRequest(), + aKnockRequest( + userId = UserId("@user:example.com"), + displayName = null, + avatarUrl = null, + reason = null, + ) + ) + ), + ), + aKnockRequestsListState( + knockRequests = AsyncData.Success( + persistentListOf( + aKnockRequest() + ) + ), + actions = KnockRequestsCurrentAction.AcceptAll(AsyncAction.Loading), + ), ) } -fun aKnockRequestsListState() = KnockRequestsListState( - eventSink = {} +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.", +) = KnockRequest( + userId = userId, + displayName = displayName, + avatarUrl = avatarUrl, + reason = reason, +) + +fun aKnockRequestsListState( + knockRequests: AsyncData> = AsyncData.Success(persistentListOf()), + actions: KnockRequestsCurrentAction = KnockRequestsCurrentAction.None, + eventSink: (KnockRequestsListEvents) -> Unit = {}, +) = KnockRequestsListState( + knockRequests = knockRequests, + currentAction = actions, + eventSink = eventSink, ) diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListView.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListView.kt index 790a970883..b8ab403c1f 100644 --- a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListView.kt +++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListView.kt @@ -7,31 +7,278 @@ package io.element.android.features.knockrequests.impl.list +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +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.features.knockrequests.impl.getBestName +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule +import io.element.android.libraries.designsystem.components.BigIcon +import io.element.android.libraries.designsystem.components.async.AsyncActionView +import io.element.android.libraries.designsystem.components.avatar.Avatar +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.aliasScreenTitle +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.HorizontalDivider +import io.element.android.libraries.designsystem.theme.components.OutlinedButton +import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.components.TextButton +import io.element.android.libraries.designsystem.theme.components.TopAppBar +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.collections.immutable.ImmutableList @Composable fun KnockRequestsListView( state: KnockRequestsListState, + onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { - Box(modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text( - "KnockRequestsList feature view", - color = MaterialTheme.colorScheme.primary, + Scaffold( + modifier = modifier, + topBar = { + KnockRequestsListTopBar(onBackClick = onBackClick) + }, + content = { padding -> + KnockRequestsListContent( + state = state, + modifier = Modifier + .padding(padding) + .consumeWindowInsets(padding), + ) + } + ) +} + +@Composable +private fun KnockRequestsListContent(state: KnockRequestsListState, modifier: Modifier) { + + fun onAcceptClick(knockRequest: KnockRequest) { + state.eventSink(KnockRequestsListEvents.Accept(knockRequest)) + } + + fun onDeclineClick(knockRequest: KnockRequest) { + state.eventSink(KnockRequestsListEvents.Decline(knockRequest)) + } + + Box(modifier, contentAlignment = Alignment.Center) { + when (state.knockRequests) { + is AsyncData.Success -> { + val knockRequests = state.knockRequests.data + if (knockRequests.isEmpty()) { + KnockRequestsListEmpty() + } else { + KnockRequestsList( + knockRequests = knockRequests, + onAcceptClick = ::onAcceptClick, + onDeclineClick = ::onDeclineClick, + ) + } + } + else -> Unit + } + KnockRequestsActionsView( + actions = state.currentAction, + onDismiss = { + state.eventSink(KnockRequestsListEvents.AcceptAll) + }, + modifier = Modifier.align(Alignment.BottomCenter), ) } } +@Composable +private fun KnockRequestsActionsView( + actions: KnockRequestsCurrentAction, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + Box(modifier) { + when (actions) { + is KnockRequestsCurrentAction.AcceptAll -> { + AsyncActionView( + async = actions.async, + onSuccess = {}, + onErrorDismiss = onDismiss, + ) + } + is KnockRequestsCurrentAction.Accept -> { + AsyncActionView( + async = actions.async, + onSuccess = {}, + onErrorDismiss = onDismiss, + ) + } + is KnockRequestsCurrentAction.Decline -> { + AsyncActionView( + async = actions.async, + onSuccess = {}, + onErrorDismiss = onDismiss, + ) + } + KnockRequestsCurrentAction.None -> Unit + } + } +} + +@Composable +private fun KnockRequestsList( + knockRequests: ImmutableList, + onAcceptClick: (KnockRequest) -> Unit, + onDeclineClick: (KnockRequest) -> Unit, + modifier: Modifier = Modifier, +) { + LazyColumn(modifier = modifier) { + itemsIndexed(knockRequests) { index, knockRequest -> + KnockRequestItem( + knockRequest = knockRequest, + onAcceptClick = onAcceptClick, + onDeclineClick = onDeclineClick, + ) + if (index != knockRequests.size - 1) { + HorizontalDivider() + } + } + } +} + +@Composable +private fun KnockRequestItem( + knockRequest: KnockRequest, + onAcceptClick: (KnockRequest) -> Unit, + onDeclineClick: (KnockRequest) -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp) + ) { + Avatar(knockRequest.getAvatarData(AvatarSize.KnockRequestItem)) + Spacer(modifier = Modifier.width(16.dp)) + Column(modifier = Modifier) { + // Name + Text( + modifier = Modifier.clipToBounds(), + text = knockRequest.getBestName(), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colorScheme.primary, + style = ElementTheme.typography.fontBodyLgMedium, + ) + // UserId + if (!knockRequest.displayName.isNullOrEmpty()) { + Text( + text = knockRequest.userId.value, + color = MaterialTheme.colorScheme.secondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = ElementTheme.typography.fontBodyMdRegular, + ) + } + // Reason + if (!knockRequest.reason.isNullOrBlank()) { + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = knockRequest.reason, + style = ElementTheme.typography.fontBodyMdRegular, + ) + } + Spacer(modifier = Modifier.height(12.dp)) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(16.dp)) { + OutlinedButton( + text = stringResource(CommonStrings.action_decline), + onClick = { + onDeclineClick(knockRequest) + }, + size = ButtonSize.MediumLowPadding, + modifier = Modifier.weight(1f), + ) + Button( + text = stringResource(CommonStrings.action_accept), + onClick = { + onAcceptClick(knockRequest) + }, + size = ButtonSize.MediumLowPadding, + modifier = Modifier.weight(1f), + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + TextButton( + text = stringResource(CommonStrings.screen_knock_requests_list_decline_and_ban_action_title), + onClick = { + onAcceptClick(knockRequest) + }, + destructive = true, + size = ButtonSize.Small, + modifier = Modifier.fillMaxWidth(), + ) + + } + } +} + +@Composable +private fun KnockRequestsListEmpty( + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier.padding( + horizontal = 32.dp, + vertical = 48.dp, + ), + contentAlignment = Alignment.Center, + ) { + IconTitleSubtitleMolecule( + title = "No pending request to join", + subTitle = "When somebody will ask to join the room, you’ll be able to see their request here.", + iconStyle = BigIcon.Style.Default(CompoundIcons.Pin()), + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun KnockRequestsListTopBar(onBackClick: () -> Unit) { + TopAppBar( + title = { + Text( + text = "Requests to join", + style = ElementTheme.typography.aliasScreenTitle, + ) + }, + navigationIcon = { BackButton(onClick = onBackClick) }, + ) +} + @PreviewsDayNight @Composable internal fun KnockRequestsListViewPreview( @@ -39,5 +286,6 @@ internal fun KnockRequestsListViewPreview( ) = ElementPreview { KnockRequestsListView( state = state, + onBackClick = {}, ) } 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 49a3e93e87..c5bd468abe 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 @@ -54,4 +54,6 @@ enum class AvatarSize(val dp: Dp) { EditProfileDetails(96.dp), Suggestion(32.dp), + + KnockRequestItem(52.dp), }