implemented the basics of the flow coordinator, the logic and the navigation flow to get to the space settings view

This commit is contained in:
Mauro Romito
2025-10-28 18:40:52 +01:00
committed by Mauro
parent 9cd181ef74
commit dfd757f2b6
19 changed files with 460 additions and 7 deletions
@@ -16,6 +16,7 @@ struct SpaceScreenCoordinatorParameters {
let spaceServiceProxy: SpaceServiceProxyProtocol
let selectedSpaceRoomPublisher: CurrentValuePublisher<String?, Never>
let userSession: UserSessionProtocol
let appSettings: AppSettings
let userIndicatorController: UserIndicatorControllerProtocol
}
@@ -25,6 +26,7 @@ enum SpaceScreenCoordinatorAction {
case selectRoom(roomID: String)
case leftSpace
case displayMembers(roomProxy: JoinedRoomProxyProtocol)
case displaySpaceSettings(roomProxy: JoinedRoomProxyProtocol)
}
final class SpaceScreenCoordinator: CoordinatorProtocol {
@@ -45,6 +47,7 @@ final class SpaceScreenCoordinator: CoordinatorProtocol {
spaceServiceProxy: parameters.spaceServiceProxy,
selectedSpaceRoomPublisher: parameters.selectedSpaceRoomPublisher,
userSession: parameters.userSession,
appSettings: parameters.appSettings,
userIndicatorController: parameters.userIndicatorController)
}
@@ -64,6 +67,8 @@ final class SpaceScreenCoordinator: CoordinatorProtocol {
actionsSubject.send(.leftSpace)
case .displayMembers(let roomProxy):
actionsSubject.send(.displayMembers(roomProxy: roomProxy))
case .displaySpaceSettings(let roomProxy):
actionsSubject.send(.displaySpaceSettings(roomProxy: roomProxy))
}
}
.store(in: &cancellables)
@@ -14,6 +14,7 @@ enum SpaceScreenViewModelAction {
case selectRoom(roomID: String)
case leftSpace
case displayMembers(roomProxy: JoinedRoomProxyProtocol)
case displaySpaceSettings(roomProxy: JoinedRoomProxyProtocol)
}
struct SpaceScreenViewState: BindableState {
@@ -15,6 +15,7 @@ class SpaceScreenViewModel: SpaceScreenViewModelType, SpaceScreenViewModelProtoc
private let spaceRoomListProxy: SpaceRoomListProxyProtocol
private let spaceServiceProxy: SpaceServiceProxyProtocol
private let clientProxy: ClientProxyProtocol
private let appSettings: AppSettings
private let userIndicatorController: UserIndicatorControllerProtocol
private let actionsSubject: PassthroughSubject<SpaceScreenViewModelAction, Never> = .init()
@@ -26,11 +27,13 @@ class SpaceScreenViewModel: SpaceScreenViewModelType, SpaceScreenViewModelProtoc
spaceServiceProxy: SpaceServiceProxyProtocol,
selectedSpaceRoomPublisher: CurrentValuePublisher<String?, Never>,
userSession: UserSessionProtocol,
appSettings: AppSettings,
userIndicatorController: UserIndicatorControllerProtocol) {
self.spaceRoomListProxy = spaceRoomListProxy
self.spaceServiceProxy = spaceServiceProxy
clientProxy = userSession.clientProxy
self.userIndicatorController = userIndicatorController
self.appSettings = appSettings
super.init(initialViewState: SpaceScreenViewState(space: spaceRoomListProxy.spaceRoomProxyPublisher.value,
rooms: spaceRoomListProxy.spaceRoomsPublisher.value,
@@ -63,10 +66,6 @@ class SpaceScreenViewModel: SpaceScreenViewModelType, SpaceScreenViewModelProtoc
}
.store(in: &cancellables)
selectedSpaceRoomPublisher
.weakAssign(to: \.state.selectedSpaceRoomID, on: self)
.store(in: &cancellables)
Task {
if case let .joined(roomProxy) = await userSession.clientProxy.roomForIdentifier(spaceRoomListProxy.id) {
// Required to listen for membership updates in the members flow
@@ -75,6 +74,22 @@ class SpaceScreenViewModel: SpaceScreenViewModelType, SpaceScreenViewModelProtoc
if case let .success(permalinkURL) = await roomProxy.matrixToPermalink() {
state.permalink = permalinkURL
}
appSettings.$spaceSettingsEnabled
.combineLatest(roomProxy.infoPublisher)
.sink { [weak self] isEnabled, info in
guard let self else { return }
guard isEnabled, let powerLevels = info.powerLevels else {
state.isSpaceManagementEnabled = false
return
}
state.isSpaceManagementEnabled = powerLevels.canOwnUserEditRolesAndPermissions() ||
powerLevels.canOwnUser(sendStateEvent: .roomName) ||
powerLevels.canOwnUser(sendStateEvent: .roomTopic) ||
powerLevels.canOwnUser(sendStateEvent: .roomAvatar)
}
.store(in: &cancellables)
}
}
}
@@ -122,7 +137,10 @@ class SpaceScreenViewModel: SpaceScreenViewModelType, SpaceScreenViewModelProtoc
case .displayMembers(let roomProxy):
actionsSubject.send(.displayMembers(roomProxy: roomProxy))
case .spaceSettings:
break // Not implemented.
guard let roomProxy = state.roomProxy else {
fatalError("Always available when the space settings button is tapped.")
}
actionsSubject.send(.displaySpaceSettings(roomProxy: roomProxy))
}
}
@@ -179,6 +197,8 @@ class SpaceScreenViewModel: SpaceScreenViewModelType, SpaceScreenViewModelProtoc
}
}
private func updatePermissions() { }
// MARK: - Indicators
private static var leavingIndicatorID: String { "\(Self.self)-Leaving" }
@@ -173,6 +173,7 @@ struct LeaveSpaceView_Previews: PreviewProvider, TestablePreview {
spaceServiceProxy: spaceServiceProxy,
selectedSpaceRoomPublisher: .init(nil),
userSession: UserSessionMock(.init()),
appSettings: AppSettings(),
userIndicatorController: UserIndicatorControllerMock())
return viewModel
}
@@ -73,6 +73,12 @@ struct SpaceScreen: View {
Label(L10n.actionShare, icon: \.shareIos)
}
}
if context.viewState.isSpaceManagementEnabled {
Button { context.send(viewAction: .spaceSettings) } label: {
Label(L10n.commonSettings, icon: \.settings)
}
}
}
Section {
@@ -121,6 +127,7 @@ struct SpaceScreen_Previews: PreviewProvider, TestablePreview {
spaceServiceProxy: SpaceServiceProxyMock(.init()),
selectedSpaceRoomPublisher: .init(nil),
userSession: userSession,
appSettings: AppSettings(),
userIndicatorController: UserIndicatorControllerMock())
return viewModel
}
@@ -0,0 +1,45 @@
//
// Copyright 2025 Element Creations Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
// Please see LICENSE files in the repository root for full details.
//
import Combine
import SwiftUI
struct SpaceSettingsScreenCoordinatorParameters { }
enum SpaceSettingsScreenCoordinatorAction { }
final class SpaceSettingsScreenCoordinator: CoordinatorProtocol {
private let parameters: SpaceSettingsScreenCoordinatorParameters
private let viewModel: SpaceSettingsScreenViewModelProtocol
private var cancellables = Set<AnyCancellable>()
private let actionsSubject: PassthroughSubject<SpaceSettingsScreenCoordinatorAction, Never> = .init()
var actionsPublisher: AnyPublisher<SpaceSettingsScreenCoordinatorAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
init(parameters: SpaceSettingsScreenCoordinatorParameters) {
self.parameters = parameters
viewModel = SpaceSettingsScreenViewModel()
}
func start() {
viewModel.actionsPublisher.sink { [weak self] action in
MXLog.info("Coordinator: received view model action: \(action)")
guard let self else { return }
switch action { }
}
.store(in: &cancellables)
}
func toPresentable() -> AnyView {
AnyView(SpaceSettingsScreen(context: viewModel.context))
}
}
@@ -0,0 +1,32 @@
//
// Copyright 2025 Element Creations Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
// Please see LICENSE files in the repository root for full details.
//
import Foundation
enum SpaceSettingsScreenViewModelAction { }
struct SpaceSettingsScreenViewState: BindableState {
var title: String
var placeholder: String
var counter = 0
var bindings: SpaceSettingsScreenViewStateBindings
}
struct SpaceSettingsScreenViewStateBindings {
var composerText: String
}
enum SpaceSettingsScreenViewAction {
case done
case textChanged
case incrementCounter
case decrementCounter
// Consider adding CustomStringConvertible conformance if the actions contain PII
}
@@ -0,0 +1,47 @@
//
// Copyright 2025 Element Creations Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
// Please see LICENSE files in the repository root for full details.
//
import Combine
import SwiftUI
typealias SpaceSettingsScreenViewModelType = StateStoreViewModelV2<SpaceSettingsScreenViewState, SpaceSettingsScreenViewAction>
class SpaceSettingsScreenViewModel: SpaceSettingsScreenViewModelType, SpaceSettingsScreenViewModelProtocol {
private let actionsSubject: PassthroughSubject<SpaceSettingsScreenViewModelAction, Never> = .init()
var actionsPublisher: AnyPublisher<SpaceSettingsScreenViewModelAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
init() {
super.init(initialViewState: SpaceSettingsScreenViewState(title: "SpaceSettings title",
placeholder: "Enter something here",
bindings: .init(composerText: "Initial composer text")))
}
// MARK: - Public
override func process(viewAction: SpaceSettingsScreenViewAction) {
MXLog.info("View model: received view action: \(viewAction)")
switch viewAction {
case .done:
break
case .textChanged:
MXLog.info("View model: composer text changed to: \(state.bindings.composerText)")
case .incrementCounter:
Task {
try await Task.sleep(for: .seconds(.random(in: 1.0...2.0)))
state.counter += 1
}
case .decrementCounter:
Task {
try await Task.sleep(for: .seconds(.random(in: 1.0...2.0)))
state.counter -= 1
}
}
}
}
@@ -0,0 +1,14 @@
//
// Copyright 2025 Element Creations Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
// Please see LICENSE files in the repository root for full details.
//
import Combine
@MainActor
protocol SpaceSettingsScreenViewModelProtocol {
var actionsPublisher: AnyPublisher<SpaceSettingsScreenViewModelAction, Never> { get }
var context: SpaceSettingsScreenViewModelType.Context { get }
}
@@ -0,0 +1,71 @@
//
// Copyright 2025 Element Creations Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
// Please see LICENSE files in the repository root for full details.
//
import Compound
import SwiftUI
struct SpaceSettingsScreen: View {
@Bindable var context: SpaceSettingsScreenViewModel.Context
var body: some View {
Form {
Section {
ListRow(label: .plain(title: context.viewState.placeholder),
kind: .textField(text: $context.composerText))
ListRow(label: .centeredAction(title: L10n.actionDone,
icon: \.leave),
kind: .button { context.send(viewAction: .done) })
}
Section {
ListRow(label: .default(title: "Counter", icon: \.chart),
details: .counter(context.viewState.counter),
kind: .label)
ListRow(label: .default(title: "Increment", icon: \.plus),
kind: .button { context.send(viewAction: .incrementCounter) })
ListRow(label: .default(title: "Decrement", icon: \.minus),
kind: .button { context.send(viewAction: .decrementCounter) })
}
}
.compoundList()
.navigationTitle(context.viewState.title)
.onChange(of: context.composerText) {
context.send(viewAction: .textChanged)
}
}
}
// MARK: - Previews
struct SpaceSettingsScreen_Previews: PreviewProvider, TestablePreview {
static let viewModel = makeViewModel()
static let incrementedViewModel = makeViewModel(counterValue: 1)
static var previews: some View {
NavigationStack {
SpaceSettingsScreen(context: viewModel.context)
}
.previewDisplayName("Initial")
NavigationStack {
SpaceSettingsScreen(context: incrementedViewModel.context)
}
.previewDisplayName("Incremented")
.snapshotPreferences(expect: incrementedViewModel.context.observe(\.viewState.counter).map { $0 == 1 }.eraseToStream())
}
static func makeViewModel(counterValue: Int = 0) -> SpaceSettingsScreenViewModel {
let viewModel = SpaceSettingsScreenViewModel()
for _ in 0..<counterValue {
viewModel.context.send(viewAction: .incrementCounter)
}
return viewModel
}
}