feature(space) : starts space settings screen

This commit is contained in:
ganfra
2025-10-30 22:15:08 +01:00
parent f7e28346d6
commit c4b8227c58
6 changed files with 388 additions and 0 deletions

View File

@@ -0,0 +1,10 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.space.impl.settings
sealed interface SpaceSettingsEvents

View File

@@ -0,0 +1,58 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.space.impl.settings
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.features.space.impl.di.SpaceFlowScope
import io.element.android.libraries.architecture.appyx.launchMolecule
@ContributesNode(SpaceFlowScope::class)
@AssistedInject
class SpaceSettingsNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: SpaceSettingsPresenter,
) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin {
fun onBackClick()
fun onSpaceInfoClick()
fun onMembersClick()
fun onRolesAndPermissionsClick()
fun onSecurityAndPrivacyClick()
fun onLeaveSpaceClick()
}
private val callback = plugins<Callback>().single()
private val stateFlow = launchMolecule { presenter.present() }
@Composable
override fun View(modifier: Modifier) {
val state by stateFlow.collectAsState()
SpaceSettingsView(
state = state,
modifier = modifier,
onSpaceInfoClick = callback::onSpaceInfoClick,
onBackClick = callback::onBackClick,
onMembersClick = callback::onMembersClick,
onRolesAndPermissionsClick = callback::onRolesAndPermissionsClick,
onSecurityAndPrivacyClick = callback::onSecurityAndPrivacyClick,
onLeaveSpaceClick = callback::onLeaveSpaceClick,
)
}
}

View File

@@ -0,0 +1,38 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.space.impl.settings
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import dev.zacsweers.metro.Inject
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.ui.room.isOwnUserAdmin
@Inject
class SpaceSettingsPresenter(
private val room: JoinedRoom,
) : Presenter<SpaceSettingsState> {
@Composable
override fun present(): SpaceSettingsState {
val roomInfo by room.roomInfoFlow.collectAsState()
val isUserAdmin = room.isOwnUserAdmin()
return SpaceSettingsState(
roomId = room.roomId,
name = roomInfo.name.orEmpty(),
canonicalAlias = roomInfo.canonicalAlias,
avatarUrl = roomInfo.avatarUrl,
memberCount = roomInfo.activeMembersCount,
showRolesAndPermissions = isUserAdmin,
showSecurityAndPrivacy = isUserAdmin,
isTombstoned = roomInfo.successorRoom != null,
eventSink = {},
)
}
}

View File

@@ -0,0 +1,23 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.space.impl.settings
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
data class SpaceSettingsState(
val roomId: RoomId,
val name: String,
val canonicalAlias: RoomAlias?,
val avatarUrl: String?,
val isTombstoned: Boolean,
val memberCount: Long,
val showRolesAndPermissions: Boolean,
val showSecurityAndPrivacy: Boolean,
val eventSink: (SpaceSettingsEvents) -> Unit
)

View File

@@ -0,0 +1,44 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.space.impl.settings
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
open class SpaceSettingsStateProvider : PreviewParameterProvider<SpaceSettingsState> {
override val values: Sequence<SpaceSettingsState>
get() = sequenceOf(
aSpaceSettingsState(),
aSpaceSettingsState(alias = null),
aSpaceSettingsState(showSecurityAndPrivacy = true),
aSpaceSettingsState(showRolesAndPermissions = true),
)
}
fun aSpaceSettingsState(
roomId: RoomId = RoomId("!aRoomId:element.io"),
name: String = "Space name",
alias: RoomAlias? = RoomAlias("#spacename:element.io"),
avatarUrl: String? = null,
memberCount: Long = 100,
isTombstoned: Boolean = false,
showRolesAndPermissions: Boolean = false,
showSecurityAndPrivacy: Boolean = false,
eventSink: (SpaceSettingsEvents) -> Unit = {},
) = SpaceSettingsState(
roomId = roomId,
name = name,
canonicalAlias = alias,
avatarUrl = avatarUrl,
isTombstoned = isTombstoned,
memberCount = memberCount,
showRolesAndPermissions = showRolesAndPermissions,
showSecurityAndPrivacy = showSecurityAndPrivacy,
eventSink = eventSink,
)

View File

@@ -0,0 +1,215 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.space.impl.settings
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
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.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.ListItemStyle
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.TopAppBar
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun SpaceSettingsView(
state: SpaceSettingsState,
onBackClick: () -> Unit,
onSpaceInfoClick: ()->Unit,
onMembersClick: () -> Unit,
onRolesAndPermissionsClick: () -> Unit,
onSecurityAndPrivacyClick: () -> Unit,
onLeaveSpaceClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Scaffold(
modifier = modifier,
topBar = {
SpaceSettingsTopBar(onBackClick = onBackClick)
},
) { padding ->
Column(
modifier = Modifier
.padding(padding)
.verticalScroll(rememberScrollState())
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onSpaceInfoClick)
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Avatar(
avatarData = AvatarData(state.roomId.value, state.name, state.avatarUrl, AvatarSize.SpaceListItem),
avatarType = AvatarType.Space(
isTombstoned = state.isTombstoned,
),
contentDescription = state.avatarUrl?.let { stringResource(CommonStrings.a11y_room_avatar) },
)
Spacer(Modifier.width(16.dp))
Column {
Text(
text = state.name,
style = ElementTheme.typography.fontHeadingMdRegular,
color = ElementTheme.colors.textPrimary,
)
if (state.canonicalAlias != null) {
Text(
text = state.canonicalAlias.value,
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary,
)
}
}
}
Section(isVisible = state.showSecurityAndPrivacy) {
SecurityAndPrivacyItem(
onClick = onSecurityAndPrivacyClick
)
}
Section {
MembersItem(state.memberCount, onClick = onMembersClick)
if (state.showRolesAndPermissions) {
RolesAndPermissionsItem(onClick = onRolesAndPermissionsClick)
}
}
Section {
LeaveSpaceItem(
onClick = onLeaveSpaceClick
)
}
}
}
}
@Composable
private fun ColumnScope.Section(
modifier: Modifier = Modifier,
isVisible: Boolean = true,
content: @Composable ColumnScope.() -> Unit,
) {
if (isVisible) {
PreferenceCategory(content = content, modifier = modifier)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun SpaceSettingsTopBar(
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) {
TopAppBar(
titleStr = stringResource(CommonStrings.common_settings),
navigationIcon = { BackButton(onClick = onBackClick) },
modifier = modifier,
)
}
@Composable
private fun SecurityAndPrivacyItem(
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
ListItem(
headlineContent = { Text("Security & privacy") },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Lock())),
onClick = onClick,
modifier = modifier,
)
}
@Composable
private fun MembersItem(
memberCount: Long,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
ListItem(
headlineContent = { Text(stringResource(CommonStrings.common_people)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.User())),
trailingContent = ListItemContent.Text(memberCount.toString()),
onClick = onClick,
modifier = modifier,
)
}
@Composable
private fun RolesAndPermissionsItem(
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
ListItem(
headlineContent = { Text("Roles & permissions") },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Admin())),
onClick = onClick,
modifier = modifier,
)
}
@Composable
private fun LeaveSpaceItem(
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
ListItem(
headlineContent = {
Text(stringResource(CommonStrings.action_leave_space))
},
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Leave())),
style = ListItemStyle.Destructive,
onClick = onClick,
modifier = modifier,
)
}
@PreviewsDayNight
@Composable
internal fun SpaceSettingsViewPreview(
@PreviewParameter(SpaceSettingsStateProvider::class) state: SpaceSettingsState
) = ElementPreview {
SpaceSettingsView(
state = state,
onBackClick = {},
onSpaceInfoClick = {},
onMembersClick = {},
onRolesAndPermissionsClick = {},
onSecurityAndPrivacyClick = {},
onLeaveSpaceClick = {},
modifier = Modifier,
)
}