From 1fcf96865c4478f9c74f3784ecf307d0deebfb59 Mon Sep 17 00:00:00 2001 From: Gil Eluard Date: Fri, 4 Mar 2022 12:53:42 +0100 Subject: [PATCH] SP4: space settings (#5730) * SP4: Space Settings - Space settings screen implemented - No space upgrade available as per Element web - Need more insights for the space address field - Added settings live update - Added local alias implementation --- Riot/Assets/en.lproj/Vector.strings | 6 +- Riot/Generated/Strings.swift | 14 +- .../Room/RoomInfo/RoomInfoCoordinator.swift | 18 + .../RoomInfoCoordinatorParameters.swift | 10 +- .../Settings/RoomSettingsViewController.h | 4 + .../Settings/RoomSettingsViewController.m | 27 +- .../SideMenu/SideMenuCoordinator.swift | 30 +- .../SpaceMemberDetailCoordinator.swift | 3 +- .../SpaceMemberDetailViewController.swift | 10 +- .../SpaceMemberDetailViewModel.swift | 4 +- .../SpaceMemberDetailViewModelType.swift | 1 + .../SpaceMemberListViewController.swift | 2 +- .../SpaceMembersCoordinator.swift | 27 +- .../ExploreRoomCoordinator.swift | 104 +++- .../Common/Avatar/View/SpaceAvatarImage.swift | 2 +- .../Common/Util/ClearViewModifier.swift | 14 + .../Common/Util/RoundedBorderTextEditor.swift | 60 ++- .../Common/Util/RoundedBorderTextField.swift | 74 ++- .../Coordinator/RoomAccessCoordinator.swift | 2 +- ...RoomAccessCoordinatorBridgePresenter.swift | 11 +- .../RoomAccessCoordinatorParameters.swift | 5 + .../RoomAccessTypeChooserCoordinator.swift | 3 +- .../RoomAccessTypeChooserService.swift | 6 +- ...omRestrictedAllowedParentsDataSource.swift | 6 +- ...ationSettingsAddressValidationStatus.swift | 25 + .../SpaceCreationSettingsService.swift | 6 +- .../SpaceCreationSettingsViewModel.swift | 30 +- .../SpaceSettingsModalCoordinator.swift | 183 +++++++ ...tingsModalCoordinatorBridgePresenter.swift | 100 ++++ ...ceSettingsModalCoordinatorParameters.swift | 38 ++ .../SpaceSettingsCoordinator.swift | 101 ++++ .../MockSpaceSettingsScreenState.swift | 84 ++++ .../MatrixSDK/SpaceSettingsService.swift | 474 ++++++++++++++++++ .../Mock/MockSpaceSettingsService.swift | 56 +++ .../SpaceSettingsServiceProtocol.swift | 49 ++ .../SpaceSettings/SpaceSettingsModels.swift | 123 +++++ .../SpaceSettingsViewModel.swift | 155 ++++++ .../SpaceSettingsViewModelProtocol.swift | 27 + .../Test/UI/SpaceSettingsUITests.swift | 35 ++ .../Unit/SpaceSettingsViewModelTests.swift | 60 +++ .../SpaceSettings/View/SpaceSettings.swift | 206 ++++++++ .../View/SpaceSettingsOptionListItem.swift | 106 ++++ changelog.d/5233.feature | 1 + 43 files changed, 2184 insertions(+), 118 deletions(-) create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceSettings/Coordinator/SpaceSettingsModalCoordinator.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceSettings/Coordinator/SpaceSettingsModalCoordinatorBridgePresenter.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceSettings/Coordinator/SpaceSettingsModalCoordinatorParameters.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceSettings/SpaceSettings/Coordinator/SpaceSettingsCoordinator.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceSettings/SpaceSettings/MockSpaceSettingsScreenState.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceSettings/SpaceSettings/Service/MatrixSDK/SpaceSettingsService.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceSettings/SpaceSettings/Service/Mock/MockSpaceSettingsService.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceSettings/SpaceSettings/Service/SpaceSettingsServiceProtocol.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceSettings/SpaceSettings/SpaceSettingsModels.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceSettings/SpaceSettings/SpaceSettingsViewModel.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceSettings/SpaceSettings/SpaceSettingsViewModelProtocol.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceSettings/SpaceSettings/Test/UI/SpaceSettingsUITests.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceSettings/SpaceSettings/Test/Unit/SpaceSettingsViewModelTests.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceSettings/SpaceSettings/View/SpaceSettings.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceSettings/SpaceSettings/View/SpaceSettingsOptionListItem.swift create mode 100644 changelog.d/5233.feature diff --git a/Riot/Assets/en.lproj/Vector.strings b/Riot/Assets/en.lproj/Vector.strings index b57cc962f..9315703d3 100644 --- a/Riot/Assets/en.lproj/Vector.strings +++ b/Riot/Assets/en.lproj/Vector.strings @@ -1886,7 +1886,11 @@ Tap the + to start adding people."; "spaces_add_space" = "Add space"; "space_public_join_rule_detail" = "Open to anyone, best for communities"; -"space_topic" = "description"; +"space_topic" = "Description"; + +"space_settings_access_section" = "Who can access this space?"; +"space_settings_update_failed_message" = "Failed to update space settings. Do you want to retry?"; +"space_settings_current_address_message" = "Your space is viewable at\n%@"; // Mark: - Space Creation diff --git a/Riot/Generated/Strings.swift b/Riot/Generated/Strings.swift index 0285a13be..685f1d71a 100644 --- a/Riot/Generated/Strings.swift +++ b/Riot/Generated/Strings.swift @@ -5543,11 +5543,23 @@ public class VectorL10n: NSObject { public static var spacePublicJoinRuleDetail: String { return VectorL10n.tr("Vector", "space_public_join_rule_detail") } + /// Who can access this space? + public static var spaceSettingsAccessSection: String { + return VectorL10n.tr("Vector", "space_settings_access_section") + } + /// Your space is viewable at\n%@ + public static func spaceSettingsCurrentAddressMessage(_ p1: String) -> String { + return VectorL10n.tr("Vector", "space_settings_current_address_message", p1) + } + /// Failed to update space settings. Do you want to retry? + public static var spaceSettingsUpdateFailedMessage: String { + return VectorL10n.tr("Vector", "space_settings_update_failed_message") + } /// space public static var spaceTag: String { return VectorL10n.tr("Vector", "space_tag") } - /// description + /// Description public static var spaceTopic: String { return VectorL10n.tr("Vector", "space_topic") } diff --git a/Riot/Modules/Room/RoomInfo/RoomInfoCoordinator.swift b/Riot/Modules/Room/RoomInfo/RoomInfoCoordinator.swift index 47a8c356a..c570c150f 100644 --- a/Riot/Modules/Room/RoomInfo/RoomInfoCoordinator.swift +++ b/Riot/Modules/Room/RoomInfo/RoomInfoCoordinator.swift @@ -30,6 +30,7 @@ final class RoomInfoCoordinator: NSObject, RoomInfoCoordinatorType { private let room: MXRoom private let parentSpaceId: String? private let initialSection: RoomInfoSection + private let dismissOnCancel: Bool private weak var roomSettingsViewController: RoomSettingsViewController? private lazy var segmentedViewController: SegmentedViewController = { @@ -103,6 +104,7 @@ final class RoomInfoCoordinator: NSObject, RoomInfoCoordinatorType { self.room = parameters.room self.parentSpaceId = parameters.parentSpaceId self.initialSection = parameters.initialSection + self.dismissOnCancel = parameters.dismissOnCancel } // MARK: - Public methods @@ -226,6 +228,22 @@ extension RoomInfoCoordinator: RoomNotificationSettingsCoordinatorDelegate { } extension RoomInfoCoordinator: RoomSettingsViewControllerDelegate { + func roomSettingsViewControllerDidCancel(_ controller: RoomSettingsViewController!) { + if self.dismissOnCancel { + self.navigationRouter.dismissModule(animated: true, completion: nil) + } else { + controller.withdrawViewController(animated: true) {} + } + } + + func roomSettingsViewControllerDidComplete(_ controller: RoomSettingsViewController!) { + if self.dismissOnCancel { + self.navigationRouter.dismissModule(animated: true, completion: nil) + } else { + controller.withdrawViewController(animated: true) {} + } + } + func roomSettingsViewController(_ controller: RoomSettingsViewController!, didReplaceRoomWithReplacementId newRoomId: String!) { self.delegate?.roomInfoCoordinator(self, didReplaceRoomWithReplacementId: newRoomId) } diff --git a/Riot/Modules/Room/RoomInfo/RoomInfoCoordinatorParameters.swift b/Riot/Modules/Room/RoomInfo/RoomInfoCoordinatorParameters.swift index 310731c2a..c1c46fab4 100644 --- a/Riot/Modules/Room/RoomInfo/RoomInfoCoordinatorParameters.swift +++ b/Riot/Modules/Room/RoomInfo/RoomInfoCoordinatorParameters.swift @@ -32,16 +32,22 @@ class RoomInfoCoordinatorParameters: NSObject { let room: MXRoom let parentSpaceId: String? let initialSection: RoomInfoSection + let dismissOnCancel: Bool - init(session: MXSession, room: MXRoom, parentSpaceId: String?, initialSection: RoomInfoSection) { + init(session: MXSession, room: MXRoom, parentSpaceId: String?, initialSection: RoomInfoSection, dismissOnCancel: Bool) { self.session = session self.room = room self.parentSpaceId = parentSpaceId self.initialSection = initialSection + self.dismissOnCancel = dismissOnCancel super.init() } convenience init(session: MXSession, room: MXRoom, parentSpaceId: String?) { - self.init(session: session, room: room, parentSpaceId: parentSpaceId, initialSection: .none) + self.init(session: session, room: room, parentSpaceId: parentSpaceId, initialSection: .none, dismissOnCancel: false) + } + + convenience init(session: MXSession, room: MXRoom, parentSpaceId: String?, initialSection: RoomInfoSection) { + self.init(session: session, room: room, parentSpaceId: parentSpaceId, initialSection: initialSection, dismissOnCancel: false) } } diff --git a/Riot/Modules/Room/Settings/RoomSettingsViewController.h b/Riot/Modules/Room/Settings/RoomSettingsViewController.h index 02a4d4214..dddb1d5ec 100644 --- a/Riot/Modules/Room/Settings/RoomSettingsViewController.h +++ b/Riot/Modules/Room/Settings/RoomSettingsViewController.h @@ -71,4 +71,8 @@ typedef enum : NSUInteger { - (void)roomSettingsViewController:(RoomSettingsViewController *)controller didReplaceRoomWithReplacementId:(NSString *)newRoomId; +- (void)roomSettingsViewControllerDidCancel:(RoomSettingsViewController *)controller; + +- (void)roomSettingsViewControllerDidComplete:(RoomSettingsViewController *)controller; + @end diff --git a/Riot/Modules/Room/Settings/RoomSettingsViewController.m b/Riot/Modules/Room/Settings/RoomSettingsViewController.m index cec6fca83..2575c4fab 100644 --- a/Riot/Modules/Room/Settings/RoomSettingsViewController.m +++ b/Riot/Modules/Room/Settings/RoomSettingsViewController.m @@ -906,7 +906,14 @@ NSString *const kRoomSettingsAdvancedE2eEnabledCellViewIdentifier = @"kRoomSetti [self->updatedItemsDict removeAllObjects]; - [self withdrawViewControllerAnimated:YES completion:nil]; + if (self.delegate) + { + [self.delegate roomSettingsViewControllerDidCancel:self]; + } + else + { + [self withdrawViewControllerAnimated:YES completion:nil]; + } } }]]; @@ -1435,7 +1442,14 @@ NSString *const kRoomSettingsAdvancedE2eEnabledCellViewIdentifier = @"kRoomSetti } else { - [self withdrawViewControllerAnimated:YES completion:nil]; + if (self.delegate) + { + [self.delegate roomSettingsViewControllerDidCancel:self]; + } + else + { + [self withdrawViewControllerAnimated:YES completion:nil]; + } } } @@ -2183,7 +2197,14 @@ NSString *const kRoomSettingsAdvancedE2eEnabledCellViewIdentifier = @"kRoomSetti [self stopActivityIndicator]; - [self withdrawViewControllerAnimated:YES completion:nil]; + if (self.delegate) + { + [self.delegate roomSettingsViewControllerDidComplete:self]; + } + else + { + [self withdrawViewControllerAnimated:YES completion:nil]; + } } #pragma mark - UITableViewDataSource diff --git a/Riot/Modules/SideMenu/SideMenuCoordinator.swift b/Riot/Modules/SideMenu/SideMenuCoordinator.swift index ad883ac34..7f5280fd8 100644 --- a/Riot/Modules/SideMenu/SideMenuCoordinator.swift +++ b/Riot/Modules/SideMenu/SideMenuCoordinator.swift @@ -66,6 +66,7 @@ final class SideMenuCoordinator: NSObject, SideMenuCoordinatorType { private var membersCoordinator: SpaceMembersCoordinator? private var createSpaceCoordinator: SpaceCreationCoordinator? private var createRoomCoordinator: CreateRoomCoordinator? + private var spaceSettingsCoordinator: Coordinator? // MARK: Public @@ -297,10 +298,27 @@ final class SideMenuCoordinator: NSObject, SideMenuCoordinatorType { presentable.presentationController?.delegate = self toPresentable().present(presentable, animated: true, completion: nil) createRoomCoordinator.start() - self.add(childCoordinator: createRoomCoordinator) self.createRoomCoordinator = createRoomCoordinator } + @available(iOS 14.0, *) + private func showSpaceSettings(spaceId: String, session: MXSession) { + let coordinator = SpaceSettingsModalCoordinator(parameters: SpaceSettingsModalCoordinatorParameters(session: session, spaceId: spaceId)) + coordinator.callback = { [weak self] result in + guard let self = self else { return } + + coordinator.toPresentable().dismiss(animated: true) { + self.spaceSettingsCoordinator = nil + } + } + + let presentable = coordinator.toPresentable() + presentable.presentationController?.delegate = self + toPresentable().present(presentable, animated: true, completion: nil) + coordinator.start() + self.spaceSettingsCoordinator = coordinator + } + // MARK: UserSessions management private func registerUserSessionsServiceNotifications() { @@ -405,7 +423,11 @@ extension SideMenuCoordinator: SpaceMenuPresenterDelegate { case .addSpace: AppDelegate.theDelegate().showAlert(withTitle: VectorL10n.spacesAddSpace, message: VectorL10n.spacesComingSoonDetail(AppInfo.current.displayName)) case .settings: - AppDelegate.theDelegate().showAlert(withTitle: VectorL10n.sideMenuActionSettings, message: VectorL10n.spacesComingSoonDetail(AppInfo.current.displayName)) + if #available(iOS 14.0, *) { + self.showSpaceSettings(spaceId: spaceId, session: session) + } else { + AppDelegate.theDelegate().showAlert(withTitle: VectorL10n.settingsTitle, message: VectorL10n.spacesComingSoonDetail(AppInfo.current.displayName)) + } } } } @@ -448,7 +470,6 @@ extension SideMenuCoordinator: SpaceMembersCoordinatorDelegate { extension SideMenuCoordinator: CreateRoomCoordinatorDelegate { func createRoomCoordinator(_ coordinator: CreateRoomCoordinatorType, didCreateNewRoom room: MXRoom) { coordinator.toPresentable().dismiss(animated: true) { - self.remove(childCoordinator: coordinator) self.createRoomCoordinator = nil self.parameters.appNavigator.sideMenu.dismiss(animated: true) { @@ -461,7 +482,6 @@ extension SideMenuCoordinator: CreateRoomCoordinatorDelegate { func createRoomCoordinator(_ coordinator: CreateRoomCoordinatorType, didAddRoomsWithIds roomIds: [String]) { coordinator.toPresentable().dismiss(animated: true) { - self.remove(childCoordinator: coordinator) self.createRoomCoordinator = nil self.parameters.appNavigator.sideMenu.dismiss(animated: true) { @@ -474,7 +494,6 @@ extension SideMenuCoordinator: CreateRoomCoordinatorDelegate { func createRoomCoordinatorDidCancel(_ coordinator: CreateRoomCoordinatorType) { coordinator.toPresentable().dismiss(animated: true) { - self.remove(childCoordinator: coordinator) self.createRoomCoordinator = nil } } @@ -488,5 +507,6 @@ extension SideMenuCoordinator: UIAdaptivePresentationControllerDelegate { self.membersCoordinator = nil self.createSpaceCoordinator = nil self.createRoomCoordinator = nil + self.spaceSettingsCoordinator = nil } } diff --git a/Riot/Modules/Spaces/SpaceMembers/MemberDetail/SpaceMemberDetailCoordinator.swift b/Riot/Modules/Spaces/SpaceMembers/MemberDetail/SpaceMemberDetailCoordinator.swift index fa62c75b5..a0b466276 100644 --- a/Riot/Modules/Spaces/SpaceMembers/MemberDetail/SpaceMemberDetailCoordinator.swift +++ b/Riot/Modules/Spaces/SpaceMembers/MemberDetail/SpaceMemberDetailCoordinator.swift @@ -24,6 +24,7 @@ struct SpaceMemberDetailCoordinatorParameters { let member: MXRoomMember let session: MXSession let spaceId: String + let showCancelMenuItem: Bool } final class SpaceMemberDetailCoordinator: NSObject, SpaceMemberDetailCoordinatorType { @@ -49,7 +50,7 @@ final class SpaceMemberDetailCoordinator: NSObject, SpaceMemberDetailCoordinator init(parameters: SpaceMemberDetailCoordinatorParameters) { self.parameters = parameters - let spaceMemberDetailViewModel = SpaceMemberDetailViewModel(userSessionsService: parameters.userSessionsService, session: parameters.session, member: parameters.member, spaceId: parameters.spaceId) + let spaceMemberDetailViewModel = SpaceMemberDetailViewModel(userSessionsService: parameters.userSessionsService, session: parameters.session, member: parameters.member, spaceId: parameters.spaceId, showCancelMenuItem: parameters.showCancelMenuItem) let spaceMemberDetailViewController = SpaceMemberDetailViewController.instantiate(with: spaceMemberDetailViewModel) spaceMemberDetailViewController.enableMention = true spaceMemberDetailViewController.enableVoipCall = false diff --git a/Riot/Modules/Spaces/SpaceMembers/MemberDetail/SpaceMemberDetailViewController.swift b/Riot/Modules/Spaces/SpaceMembers/MemberDetail/SpaceMemberDetailViewController.swift index 136419f8d..999e9fb85 100644 --- a/Riot/Modules/Spaces/SpaceMembers/MemberDetail/SpaceMemberDetailViewController.swift +++ b/Riot/Modules/Spaces/SpaceMembers/MemberDetail/SpaceMemberDetailViewController.swift @@ -106,11 +106,13 @@ final class SpaceMemberDetailViewController: RoomMemberDetailsViewController { } private func setupViews() { - let cancelBarButtonItem = MXKBarButtonItem(title: VectorL10n.cancel, style: .plain) { [weak self] in - self?.cancelButtonAction() + if viewModel.showCancelMenuItem { + let cancelBarButtonItem = MXKBarButtonItem(title: VectorL10n.cancel, style: .plain) { [weak self] in + self?.cancelButtonAction() + } + + self.navigationItem.rightBarButtonItem = cancelBarButtonItem } - - self.navigationItem.rightBarButtonItem = cancelBarButtonItem } private func render(viewState: SpaceMemberDetailViewState) { diff --git a/Riot/Modules/Spaces/SpaceMembers/MemberDetail/SpaceMemberDetailViewModel.swift b/Riot/Modules/Spaces/SpaceMembers/MemberDetail/SpaceMemberDetailViewModel.swift index f462dda89..114584869 100644 --- a/Riot/Modules/Spaces/SpaceMembers/MemberDetail/SpaceMemberDetailViewModel.swift +++ b/Riot/Modules/Spaces/SpaceMembers/MemberDetail/SpaceMemberDetailViewModel.swift @@ -29,6 +29,7 @@ final class SpaceMemberDetailViewModel: NSObject, SpaceMemberDetailViewModelType private let member: MXRoomMember private let spaceId: String private var space: MXSpace? + private(set) var showCancelMenuItem: Bool private var currentOperation: MXHTTPOperation? @@ -39,11 +40,12 @@ final class SpaceMemberDetailViewModel: NSObject, SpaceMemberDetailViewModelType // MARK: - Setup - init(userSessionsService: UserSessionsService, session: MXSession, member: MXRoomMember, spaceId: String) { + init(userSessionsService: UserSessionsService, session: MXSession, member: MXRoomMember, spaceId: String, showCancelMenuItem: Bool) { self.userSessionsService = userSessionsService self.session = session self.member = member self.spaceId = spaceId + self.showCancelMenuItem = showCancelMenuItem } deinit { diff --git a/Riot/Modules/Spaces/SpaceMembers/MemberDetail/SpaceMemberDetailViewModelType.swift b/Riot/Modules/Spaces/SpaceMembers/MemberDetail/SpaceMemberDetailViewModelType.swift index 86d7e8b7d..009f47b33 100644 --- a/Riot/Modules/Spaces/SpaceMembers/MemberDetail/SpaceMemberDetailViewModelType.swift +++ b/Riot/Modules/Spaces/SpaceMembers/MemberDetail/SpaceMemberDetailViewModelType.swift @@ -32,6 +32,7 @@ protocol SpaceMemberDetailViewModelType { var viewDelegate: SpaceMemberDetailViewModelViewDelegate? { get set } var coordinatorDelegate: SpaceMemberDetailViewModelCoordinatorDelegate? { get set } + var showCancelMenuItem: Bool { get } func process(viewAction: SpaceMemberDetailViewAction) } diff --git a/Riot/Modules/Spaces/SpaceMembers/MemberList/SpaceMemberListViewController.swift b/Riot/Modules/Spaces/SpaceMembers/MemberList/SpaceMemberListViewController.swift index 559a761c5..ccb6edd6f 100644 --- a/Riot/Modules/Spaces/SpaceMembers/MemberList/SpaceMemberListViewController.swift +++ b/Riot/Modules/Spaces/SpaceMembers/MemberList/SpaceMemberListViewController.swift @@ -110,7 +110,7 @@ final class SpaceMemberListViewController: RoomParticipantsViewController { self?.cancelButtonAction() } - self.navigationItem.rightBarButtonItem = cancelBarButtonItem + self.navigationItem.leftBarButtonItem = cancelBarButtonItem self.titleView = MainTitleView() self.titleView.titleLabel.text = VectorL10n.roomDetailsPeople diff --git a/Riot/Modules/Spaces/SpaceMembers/SpaceMembersCoordinator.swift b/Riot/Modules/Spaces/SpaceMembers/SpaceMembersCoordinator.swift index 844d9c539..e1fcc1f9d 100644 --- a/Riot/Modules/Spaces/SpaceMembers/SpaceMembersCoordinator.swift +++ b/Riot/Modules/Spaces/SpaceMembers/SpaceMembersCoordinator.swift @@ -22,6 +22,17 @@ struct SpaceMembersCoordinatorParameters { let userSessionsService: UserSessionsService let session: MXSession let spaceId: String + let navigationRouter: NavigationRouterType + + init(userSessionsService: UserSessionsService, + session: MXSession, + spaceId: String, + navigationRouter: NavigationRouterType = NavigationRouter(navigationController: RiotNavigationController())) { + self.userSessionsService = userSessionsService + self.session = session + self.spaceId = spaceId + self.navigationRouter = navigationRouter + } } @objcMembers @@ -46,7 +57,7 @@ final class SpaceMembersCoordinator: SpaceMembersCoordinatorType { init(parameters: SpaceMembersCoordinatorParameters) { self.parameters = parameters - self.navigationRouter = NavigationRouter(navigationController: RiotNavigationController()) + self.navigationRouter = parameters.navigationRouter } // MARK: - Public methods @@ -59,8 +70,15 @@ final class SpaceMembersCoordinator: SpaceMembersCoordinatorType { self.add(childCoordinator: rootCoordinator) - self.navigationRouter.setRootModule(rootCoordinator) - } + if self.navigationRouter.modules.isEmpty { + self.navigationRouter.setRootModule(rootCoordinator) + } else { + self.navigationRouter.push(rootCoordinator, animated: true) { + self.remove(childCoordinator: rootCoordinator) + } + } + + } func toPresentable() -> UIViewController { return self.navigationRouter.toPresentable() @@ -99,7 +117,7 @@ final class SpaceMembersCoordinator: SpaceMembersCoordinatorType { } private func createSpaceMemberDetailCoordinator(with member: MXRoomMember) -> SpaceMemberDetailCoordinator { - let parameters = SpaceMemberDetailCoordinatorParameters(userSessionsService: self.parameters.userSessionsService, member: member, session: self.parameters.session, spaceId: self.parameters.spaceId) + let parameters = SpaceMemberDetailCoordinatorParameters(userSessionsService: self.parameters.userSessionsService, member: member, session: self.parameters.session, spaceId: self.parameters.spaceId, showCancelMenuItem: false) let coordinator = SpaceMemberDetailCoordinator(parameters: parameters) coordinator.delegate = self return coordinator @@ -158,6 +176,7 @@ extension SpaceMembersCoordinator: SpaceMemberListCoordinatorDelegate { coordinator.delegate = self coordinator.start() self.childCoordinators.append(coordinator) + self.navigationRouter.present(coordinator.toPresentable(), animated: true) } } } diff --git a/Riot/Modules/Spaces/SpaceRoomList/ExploreRoomCoordinator.swift b/Riot/Modules/Spaces/SpaceRoomList/ExploreRoomCoordinator.swift index ad0f04153..f605e0602 100644 --- a/Riot/Modules/Spaces/SpaceRoomList/ExploreRoomCoordinator.swift +++ b/Riot/Modules/Spaces/SpaceRoomList/ExploreRoomCoordinator.swift @@ -47,8 +47,9 @@ final class ExploreRoomCoordinator: NSObject, ExploreRoomCoordinatorType { // MARK: - Setup - init(session: MXSession, spaceId: String) { - self.navigationRouter = NavigationRouter(navigationController: RiotNavigationController()) + init(session: MXSession, spaceId: String, + navigationRouter: NavigationRouterType = NavigationRouter(navigationController: RiotNavigationController())) { + self.navigationRouter = navigationRouter self.session = session self.spaceId = spaceId self.spaceIdStack = [spaceId] @@ -65,7 +66,13 @@ final class ExploreRoomCoordinator: NSObject, ExploreRoomCoordinatorType { self.add(childCoordinator: rootCoordinator) self.currentExploreRoomCoordinator = rootCoordinator - self.navigationRouter.setRootModule(rootCoordinator) + if self.navigationRouter.modules.isEmpty { + self.navigationRouter.setRootModule(rootCoordinator) + } else { + self.navigationRouter.push(rootCoordinator, animated: true) { + self.remove(childCoordinator: rootCoordinator) + } + } } func toPresentable() -> UIViewController { @@ -75,13 +82,17 @@ final class ExploreRoomCoordinator: NSObject, ExploreRoomCoordinatorType { // MARK: - Private methods private func pushSpace(with item: SpaceExploreRoomListItemViewData) { - let coordinator = self.createShowSpaceExploreRoomCoordinator(session: self.session, spaceId: item.childInfo.childRoomId, spaceName: item.childInfo.name) + pushSpace(with: item.childInfo.childRoomId, name: item.childInfo.name) + } + + private func pushSpace(with spaceId: String, name: String?) { + let coordinator = self.createShowSpaceExploreRoomCoordinator(session: self.session, spaceId: spaceId, spaceName: name) coordinator.start() self.add(childCoordinator: coordinator) self.currentExploreRoomCoordinator = coordinator - self.spaceIdStack.append(item.childInfo.childRoomId) + self.spaceIdStack.append(spaceId) self.navigationRouter.push(coordinator.toPresentable(), animated: true) { self.remove(childCoordinator: coordinator) @@ -174,7 +185,7 @@ final class ExploreRoomCoordinator: NSObject, ExploreRoomCoordinatorType { } } - private func pushInviteScreen(forRoomWithId roomId: String) { + private func presentInviteScreen(forRoomWithId roomId: String) { guard let room = session.room(withRoomId: roomId) else { MXLog.error("[ExploreRoomCoordinator] pushInviteScreen: room not found.") return @@ -183,7 +194,44 @@ final class ExploreRoomCoordinator: NSObject, ExploreRoomCoordinatorType { let coordinator = ContactsPickerCoordinator(session: session, room: room, initialSearchText: nil, actualParticipants: nil, invitedParticipants: nil, userParticipant: nil, navigationRouter: navigationRouter) coordinator.delegate = self coordinator.start() - childCoordinators.append(coordinator) + self.add(childCoordinator: coordinator) + self.navigationRouter.present(coordinator, animated: true) + } + + @available(iOS 14.0, *) + private func showSpaceSettings(of childInfo: MXSpaceChildInfo) { + let coordinator = SpaceSettingsModalCoordinator(parameters: SpaceSettingsModalCoordinatorParameters(session: session, spaceId: childInfo.childRoomId)) + coordinator.callback = { [weak self] result in + guard let self = self else { return } + + switch result { + case .cancel(let spaceId), .done(let spaceId): + if spaceId != childInfo.childRoomId { + // the space has been upgraded. We need to refresh the rooms list + self.currentExploreRoomCoordinator?.reloadRooms() + } + + self.navigationRouter.dismissModule(animated: true) { + self.remove(childCoordinator: coordinator) + } + } + } + coordinator.start() + self.add(childCoordinator: coordinator) + self.navigationRouter.present(coordinator.toPresentable(), animated: true) + } + + private func presentSettings(ofRoomWithId roomId: String) -> Bool { + guard let room = session.room(withRoomId: roomId) else { + return false + } + + let coordinator = RoomInfoCoordinator(parameters: RoomInfoCoordinatorParameters(session: session, room: room, parentSpaceId: self.spaceIdStack.last, initialSection: .settings, dismissOnCancel: true)) + coordinator.delegate = self + add(childCoordinator: coordinator) + coordinator.start() + self.navigationRouter.present(coordinator.toPresentable(), animated: true) + return true } private func startEditPollCoordinator(room: MXRoom, startEvent: MXEvent? = nil) { @@ -229,11 +277,19 @@ extension ExploreRoomCoordinator: SpaceExploreRoomCoordinatorDelegate { } func spaceExploreRoomCoordinator(_ coordinator: SpaceExploreRoomCoordinatorType, openSettingsOf item: SpaceExploreRoomListItemViewData) { - self.navigateTo(roomWith: item.childInfo.childRoomId, showSettingsInitially: true, animated: true) + if item.childInfo.roomType == .space { + if #available(iOS 14, *) { + self.showSpaceSettings(of: item.childInfo) + } + } else { + if !presentSettings(ofRoomWithId: item.childInfo.childRoomId) { + self.navigateTo(roomWith: item.childInfo.childRoomId, showSettingsInitially: true, animated: true) + } + } } func spaceExploreRoomCoordinator(_ coordinator: SpaceExploreRoomCoordinatorType, inviteTo item: SpaceExploreRoomListItemViewData) { - self.pushInviteScreen(forRoomWithId: item.childInfo.childRoomId) + self.presentInviteScreen(forRoomWithId: item.childInfo.childRoomId) } } @@ -407,3 +463,33 @@ extension ExploreRoomCoordinator: ContactsPickerCoordinatorDelegate { } } + +// MARK: - RoomInfoCoordinatorDelegate +extension ExploreRoomCoordinator: RoomInfoCoordinatorDelegate { + func roomInfoCoordinatorDidComplete(_ coordinator: RoomInfoCoordinatorType) { + self.navigationRouter.dismissModule(animated: true) { + self.remove(childCoordinator: coordinator) + } + } + + func roomInfoCoordinator(_ coordinator: RoomInfoCoordinatorType, didRequestMentionForMember member: MXRoomMember) { + // Do nothing in this case + } + + func roomInfoCoordinatorDidLeaveRoom(_ coordinator: RoomInfoCoordinatorType) { + self.currentExploreRoomCoordinator?.reloadRooms() + + self.navigationRouter.dismissModule(animated: true) { + self.remove(childCoordinator: coordinator) + } + } + + func roomInfoCoordinator(_ coordinator: RoomInfoCoordinatorType, didReplaceRoomWithReplacementId roomId: String) { + self.currentExploreRoomCoordinator?.reloadRooms() + + self.navigationRouter.dismissModule(animated: true) { + self.remove(childCoordinator: coordinator) + } + } + +} diff --git a/RiotSwiftUI/Modules/Common/Avatar/View/SpaceAvatarImage.swift b/RiotSwiftUI/Modules/Common/Avatar/View/SpaceAvatarImage.swift index c33bc3c73..5908583b0 100644 --- a/RiotSwiftUI/Modules/Common/Avatar/View/SpaceAvatarImage.swift +++ b/RiotSwiftUI/Modules/Common/Avatar/View/SpaceAvatarImage.swift @@ -48,7 +48,7 @@ struct SpaceAvatarImage: View { Image(uiImage: image) .resizable() .frame(width: CGFloat(size.rawValue), height: CGFloat(size.rawValue)) - .clipShape(Circle()) + .clipShape(RoundedRectangle(cornerRadius: 8)) } } .onChange(of: displayName, perform: { value in diff --git a/RiotSwiftUI/Modules/Common/Util/ClearViewModifier.swift b/RiotSwiftUI/Modules/Common/Util/ClearViewModifier.swift index 116c5d610..ca404d749 100644 --- a/RiotSwiftUI/Modules/Common/Util/ClearViewModifier.swift +++ b/RiotSwiftUI/Modules/Common/Util/ClearViewModifier.swift @@ -16,6 +16,20 @@ import SwiftUI +@available(iOS 14.0, *) +extension ThemableTextField { + func showClearButton(text: Binding, alignement: VerticalAlignment = .center) -> some View { + return modifier(ClearViewModifier(alignment: alignement, text: text)) + } +} + +@available(iOS 14.0, *) +extension ThemableTextEditor { + func showClearButton(text: Binding, alignement: VerticalAlignment = .top) -> some View { + return modifier(ClearViewModifier(alignment: alignement, text: text)) + } +} + /// `ClearViewModifier` aims to add a clear button (e.g. `x` button) on the right side of any text editing view @available(iOS 14.0, *) struct ClearViewModifier: ViewModifier diff --git a/RiotSwiftUI/Modules/Common/Util/RoundedBorderTextEditor.swift b/RiotSwiftUI/Modules/Common/Util/RoundedBorderTextEditor.swift index 3ec7141c8..893fac5cc 100644 --- a/RiotSwiftUI/Modules/Common/Util/RoundedBorderTextEditor.swift +++ b/RiotSwiftUI/Modules/Common/Util/RoundedBorderTextEditor.swift @@ -35,6 +35,7 @@ struct RoundedBorderTextEditor: View { // MARK: Private @Environment(\.theme) private var theme: ThemeSwiftUI + @Environment(\.isEnabled) private var isEnabled // MARK: Setup @@ -73,18 +74,32 @@ struct RoundedBorderTextEditor: View { .foregroundColor(theme.colors.tertiaryContent) .allowsHitTesting(false) } - ThemableTextEditor(text: $text, onEditingChanged: { edit in - self.editing = edit - onEditingChanged?(edit) - }) - .modifier(ClearViewModifier(alignment: .top, text: $text)) - // Found no good solution here. Hidding next button for the moment -// .modifier(NextViewModifier(alignment: .bottomTrailing, isEditing: $editing)) - .padding(EdgeInsets(top: 2, leading: 6, bottom: 0, trailing: 0)) - .onChange(of: text, perform: { newText in - onTextChanged?(newText) - }) + if isEnabled { + ThemableTextEditor(text: $text, onEditingChanged: { edit in + self.editing = edit + onEditingChanged?(edit) + }) + .showClearButton(text: $text) + // Found no good solution here. Hidding next button for the moment + // .modifier(NextViewModifier(alignment: .bottomTrailing, isEditing: $editing)) + .padding(EdgeInsets(top: 2, leading: 6, bottom: 0, trailing: 0)) + .onChange(of: text, perform: { newText in + onTextChanged?(newText) + }) + } else { + ThemableTextEditor(text: $text, onEditingChanged: { edit in + self.editing = edit + onEditingChanged?(edit) + }) + .padding(EdgeInsets(top: 2, leading: 6, bottom: 0, trailing: 6)) + .onChange(of: text, perform: { newText in + onTextChanged?(newText) + }) + .opacity(0.5) + .allowsHitTesting(false) + } } + .background(RoundedRectangle(cornerRadius: 8).fill(theme.colors.background)) .overlay(RoundedRectangle(cornerRadius: 8) .stroke(editing ? theme.colors.accent : (error == nil ? theme.colors.quinaryContent : theme.colors.alert), lineWidth: editing || error != nil ? 2 : 1)) .frame(height: textMaxHeight) @@ -108,18 +123,19 @@ struct ThemableTextEditor_Previews: PreviewProvider { static var previews: some View { Group { - VStack(alignment: .center, spacing: 40) { - RoundedBorderTextEditor(title: "A title", placeHolder: "A placeholder", text: .constant(""), error: .constant(nil)) - RoundedBorderTextEditor(placeHolder: "A placeholder", text: .constant("Some text"), error: .constant(nil)) - RoundedBorderTextEditor(title: "A title", placeHolder: "A placeholder", text: .constant("Some very long text used to check overlapping with the delete button"), error: .constant("Some error text")) - } - VStack(alignment: .center, spacing: 40) { - RoundedBorderTextEditor(title: "A title", placeHolder: "A placeholder", text: .constant(""), error: .constant(nil)) - RoundedBorderTextEditor(placeHolder: "A placeholder", text: .constant("Some text"), error: .constant(nil)) - RoundedBorderTextEditor(title: "A title", placeHolder: "A placeholder", text: .constant("Some text"), error: .constant("Some error text")) - } - .theme(.dark).preferredColorScheme(.dark) + sampleView.theme(.light).preferredColorScheme(.light) + sampleView.theme(.dark).preferredColorScheme(.dark) } .padding() } + + static var sampleView: some View { + VStack(alignment: .center, spacing: 40) { + RoundedBorderTextEditor(title: "A title", placeHolder: "A placeholder", text: .constant(""), error: .constant(nil)) + RoundedBorderTextEditor(placeHolder: "A placeholder", text: .constant("Some text"), error: .constant(nil)) + RoundedBorderTextEditor(title: "A title", placeHolder: "A placeholder", text: .constant("Some very long text used to check overlapping with the delete button"), error: .constant("Some error text")) + RoundedBorderTextEditor(title: "A title", placeHolder: "A placeholder", text: .constant("Some very long text used to check overlapping with the delete button"), error: .constant("Some error text")) + .disabled(true) + } + } } diff --git a/RiotSwiftUI/Modules/Common/Util/RoundedBorderTextField.swift b/RiotSwiftUI/Modules/Common/Util/RoundedBorderTextField.swift index eaf9c8ba0..6ac3313d3 100644 --- a/RiotSwiftUI/Modules/Common/Util/RoundedBorderTextField.swift +++ b/RiotSwiftUI/Modules/Common/Util/RoundedBorderTextField.swift @@ -39,10 +39,19 @@ struct RoundedBorderTextField: View { @State private var editing = false @Environment(\.theme) private var theme: ThemeSwiftUI - + @Environment(\.isEnabled) private var isEnabled + // MARK: Setup - init(title: String? = nil, placeHolder: String, text: Binding, footerText: Binding = .constant(nil), isError: Binding = .constant(false), isFirstResponder: Bool = false, configuration: UIKitTextInputConfiguration = UIKitTextInputConfiguration(), onTextChanged: ((String) -> Void)? = nil, onEditingChanged: ((Bool) -> Void)? = nil) { + init(title: String? = nil, + placeHolder: String, + text: Binding, + footerText: Binding = .constant(nil), + isError: Binding = .constant(false), + isFirstResponder: Bool = false, + configuration: UIKitTextInputConfiguration = UIKitTextInputConfiguration(), + onTextChanged: ((String) -> Void)? = nil, + onEditingChanged: ((Bool) -> Void)? = nil) { self.title = title self.placeHolder = placeHolder self._text = text @@ -72,18 +81,33 @@ struct RoundedBorderTextField: View { .foregroundColor(theme.colors.tertiaryContent) .lineLimit(1) } - ThemableTextField(placeholder: "", text: $text, configuration: configuration, onEditingChanged: { edit in - self.editing = edit - onEditingChanged?(edit) - }) - .makeFirstResponder(isFirstResponder) - .onChange(of: text, perform: { newText in - onTextChanged?(newText) - }) - .frame(height: 30) - .modifier(ClearViewModifier(alignment: .center, text: $text)) + if isEnabled { + ThemableTextField(placeholder: "", text: $text, configuration: configuration, onEditingChanged: { edit in + self.editing = edit + onEditingChanged?(edit) + }) + .makeFirstResponder(isFirstResponder) + .showClearButton(text: $text) + .onChange(of: text, perform: { newText in + onTextChanged?(newText) + }) + .frame(height: 30) + } else { + ThemableTextField(placeholder: "", text: $text, configuration: configuration, onEditingChanged: { edit in + self.editing = edit + onEditingChanged?(edit) + }) + .makeFirstResponder(isFirstResponder) + .onChange(of: text, perform: { newText in + onTextChanged?(newText) + }) + .frame(height: 30) + .allowsHitTesting(false) + .opacity(0.5) + } } .padding(EdgeInsets(top: 8, leading: 8, bottom: 8, trailing: text.isEmpty ? 8 : 0)) + .background(RoundedRectangle(cornerRadius: 8).fill(theme.colors.background)) .overlay(RoundedRectangle(cornerRadius: 8) .stroke(editing ? theme.colors.accent : (footerText != nil && isError ? theme.colors.alert : theme.colors.quinaryContent), lineWidth: editing || (footerText != nil && isError) ? 2 : 1)) @@ -107,20 +131,20 @@ struct TextFieldWithError_Previews: PreviewProvider { static var previews: some View { Group { - VStack(alignment: .center, spacing: 40) { - RoundedBorderTextField(title: "A title", placeHolder: "A placeholder", text: .constant(""), footerText: .constant(nil), isError: .constant(false)) - RoundedBorderTextField(placeHolder: "A placeholder", text: .constant("Some text"), footerText: .constant(nil), isError: .constant(false)) - RoundedBorderTextField(title: "A title", placeHolder: "A placeholder", text: .constant("Some very long text used to check overlapping with the delete button"), footerText: .constant("Some error text"), isError: .constant(true)) - RoundedBorderTextField(title: "A title", placeHolder: "A placeholder", text: .constant("Some very long text used to check overlapping with the delete button"), footerText: .constant("Some normal text"), isError: .constant(false)) - } - - VStack(alignment: .center, spacing: 20) { - RoundedBorderTextField(title: "A title", placeHolder: "A placeholder", text: .constant(""), footerText: .constant(nil), isError: .constant(false)) - RoundedBorderTextField(placeHolder: "A placeholder", text: .constant("Some text"), footerText: .constant(nil), isError: .constant(false)) - RoundedBorderTextField(title: "A title", placeHolder: "A placeholder", text: .constant("Some very long text used to check overlapping with the delete button"), footerText: .constant("Some error text"), isError: .constant(true)) - RoundedBorderTextField(title: "A title", placeHolder: "A placeholder", text: .constant("Some very long text used to check overlapping with the delete button"), footerText: .constant("Some normal text"), isError: .constant(false)) - }.theme(.dark).preferredColorScheme(.dark) + sampleView.theme(.light).preferredColorScheme(.light) + sampleView.theme(.dark).preferredColorScheme(.dark) } .padding() } + + static var sampleView: some View { + VStack(alignment: .center, spacing: 20) { + RoundedBorderTextField(title: "A title", placeHolder: "A placeholder", text: .constant(""), footerText: .constant(nil), isError: .constant(false)) + RoundedBorderTextField(placeHolder: "A placeholder", text: .constant("Some text"), footerText: .constant(nil), isError: .constant(false)) + RoundedBorderTextField(title: "A title", placeHolder: "A placeholder", text: .constant("Some very long text used to check overlapping with the delete button"), footerText: .constant("Some error text"), isError: .constant(true)) + RoundedBorderTextField(title: "A title", placeHolder: "A placeholder", text: .constant("Some very long text used to check overlapping with the delete button"), footerText: .constant("Some normal text"), isError: .constant(false)) + RoundedBorderTextField(title: "A title", placeHolder: "A placeholder", text: .constant("Some very long text used to check overlapping with the delete button"), footerText: .constant("Some normal text"), isError: .constant(false)) + .disabled(true) + } + } } diff --git a/RiotSwiftUI/Modules/Room/RoomAccess/Coordinator/RoomAccessCoordinator.swift b/RiotSwiftUI/Modules/Room/RoomAccess/Coordinator/RoomAccessCoordinator.swift index 888e4c277..fcdf7124c 100644 --- a/RiotSwiftUI/Modules/Room/RoomAccess/Coordinator/RoomAccessCoordinator.swift +++ b/RiotSwiftUI/Modules/Room/RoomAccess/Coordinator/RoomAccessCoordinator.swift @@ -110,7 +110,7 @@ final class RoomAccessCoordinator: Coordinator { } private func createRoomAccessTypeCoordinator() -> RoomAccessTypeChooserCoordinator { - let coordinator: RoomAccessTypeChooserCoordinator = RoomAccessTypeChooserCoordinator(parameters: RoomAccessTypeChooserCoordinatorParameters(roomId: parameters.room.roomId, session: parameters.room.mxSession)) + let coordinator: RoomAccessTypeChooserCoordinator = RoomAccessTypeChooserCoordinator(parameters: RoomAccessTypeChooserCoordinatorParameters(roomId: parameters.room.roomId, allowsRoomUpgrade: parameters.allowsRoomUpgrade, session: parameters.room.mxSession)) coordinator.callback = { [weak self] result in guard let self = self else { return } diff --git a/RiotSwiftUI/Modules/Room/RoomAccess/Coordinator/RoomAccessCoordinatorBridgePresenter.swift b/RiotSwiftUI/Modules/Room/RoomAccess/Coordinator/RoomAccessCoordinatorBridgePresenter.swift index 36d48a88d..4b3a66978 100644 --- a/RiotSwiftUI/Modules/Room/RoomAccess/Coordinator/RoomAccessCoordinatorBridgePresenter.swift +++ b/RiotSwiftUI/Modules/Room/RoomAccess/Coordinator/RoomAccessCoordinatorBridgePresenter.swift @@ -34,6 +34,7 @@ final class RoomAccessCoordinatorBridgePresenter: NSObject { // MARK: Private private let room: MXRoom + private let allowsRoomUpgrade: Bool private var coordinator: RoomAccessCoordinator? // MARK: Public @@ -42,16 +43,22 @@ final class RoomAccessCoordinatorBridgePresenter: NSObject { // MARK: - Setup - init(room: MXRoom) { + init(room: MXRoom, + allowsRoomUpgrade: Bool) { self.room = room + self.allowsRoomUpgrade = allowsRoomUpgrade super.init() } + convenience init(room: MXRoom) { + self.init(room: room, allowsRoomUpgrade: true) + } + // MARK: - Public func present(from viewController: UIViewController, animated: Bool) { let navigationRouter = NavigationRouter() - let coordinator = RoomAccessCoordinator(parameters: RoomAccessCoordinatorParameters(room: room, navigationRouter: navigationRouter)) + let coordinator = RoomAccessCoordinator(parameters: RoomAccessCoordinatorParameters(room: room, allowsRoomUpgrade: allowsRoomUpgrade, navigationRouter: navigationRouter)) coordinator.callback = { [weak self] result in guard let self = self else { return } diff --git a/RiotSwiftUI/Modules/Room/RoomAccess/Coordinator/RoomAccessCoordinatorParameters.swift b/RiotSwiftUI/Modules/Room/RoomAccess/Coordinator/RoomAccessCoordinatorParameters.swift index e8803befb..cacf8ac89 100644 --- a/RiotSwiftUI/Modules/Room/RoomAccess/Coordinator/RoomAccessCoordinatorParameters.swift +++ b/RiotSwiftUI/Modules/Room/RoomAccess/Coordinator/RoomAccessCoordinatorParameters.swift @@ -23,13 +23,18 @@ struct RoomAccessCoordinatorParameters { /// The Matrix room let room: MXRoom + + /// Set this value to false if you want to avoid room to be upgraded + let allowsRoomUpgrade: Bool /// The navigation router that manage physical navigation let navigationRouter: NavigationRouterType init(room: MXRoom, + allowsRoomUpgrade: Bool = true, navigationRouter: NavigationRouterType? = nil) { self.room = room + self.allowsRoomUpgrade = allowsRoomUpgrade self.navigationRouter = navigationRouter ?? NavigationRouter(navigationController: RiotNavigationController()) } } diff --git a/RiotSwiftUI/Modules/Room/RoomAccess/RoomAccessTypeChooser/Coordinator/RoomAccessTypeChooserCoordinator.swift b/RiotSwiftUI/Modules/Room/RoomAccess/RoomAccessTypeChooser/Coordinator/RoomAccessTypeChooserCoordinator.swift index fdf510878..c88bcd26d 100644 --- a/RiotSwiftUI/Modules/Room/RoomAccess/RoomAccessTypeChooser/Coordinator/RoomAccessTypeChooserCoordinator.swift +++ b/RiotSwiftUI/Modules/Room/RoomAccess/RoomAccessTypeChooser/Coordinator/RoomAccessTypeChooserCoordinator.swift @@ -18,6 +18,7 @@ import SwiftUI struct RoomAccessTypeChooserCoordinatorParameters { let roomId: String + let allowsRoomUpgrade: Bool let session: MXSession } @@ -42,7 +43,7 @@ final class RoomAccessTypeChooserCoordinator: Coordinator, Presentable { @available(iOS 14.0, *) init(parameters: RoomAccessTypeChooserCoordinatorParameters) { self.parameters = parameters - let viewModel = RoomAccessTypeChooserViewModel(roomAccessTypeChooserService: RoomAccessTypeChooserService(roomId: parameters.roomId, session: parameters.session)) + let viewModel = RoomAccessTypeChooserViewModel(roomAccessTypeChooserService: RoomAccessTypeChooserService(roomId: parameters.roomId, allowsRoomUpgrade: parameters.allowsRoomUpgrade, session: parameters.session)) let room = parameters.session.room(withRoomId: parameters.roomId) let view = RoomAccessTypeChooser(viewModel: viewModel.context, roomName: room?.displayName ?? "") roomAccessTypeChooserViewModel = viewModel diff --git a/RiotSwiftUI/Modules/Room/RoomAccess/RoomAccessTypeChooser/Service/MatrixSDK/RoomAccessTypeChooserService.swift b/RiotSwiftUI/Modules/Room/RoomAccess/RoomAccessTypeChooser/Service/MatrixSDK/RoomAccessTypeChooserService.swift index 68c5c5d28..79a8871e6 100644 --- a/RiotSwiftUI/Modules/Room/RoomAccess/RoomAccessTypeChooser/Service/MatrixSDK/RoomAccessTypeChooserService.swift +++ b/RiotSwiftUI/Modules/Room/RoomAccess/RoomAccessTypeChooser/Service/MatrixSDK/RoomAccessTypeChooserService.swift @@ -26,6 +26,7 @@ class RoomAccessTypeChooserService: RoomAccessTypeChooserServiceProtocol { // MARK: Private private let roomId: String + private let allowsRoomUpgrade: Bool private let session: MXSession private var replacementRoom: MXRoom? private var didBuildSpaceGraphObserver: Any? @@ -63,8 +64,9 @@ class RoomAccessTypeChooserService: RoomAccessTypeChooserServiceProtocol { // MARK: - Setup - init(roomId: String, session: MXSession) { + init(roomId: String, allowsRoomUpgrade: Bool, session: MXSession) { self.roomId = roomId + self.allowsRoomUpgrade = allowsRoomUpgrade self.session = session self.currentRoomId = roomId self.versionOverride = session.homeserverCapabilitiesService.versionOverrideForFeature(.restricted) @@ -144,7 +146,7 @@ class RoomAccessTypeChooserService: RoomAccessTypeChooserServiceProtocol { // MARK: - Private private func setupAccessItems() { - guard let spaceService = session.spaceService, let ancestors = spaceService.ancestorsPerRoomId[currentRoomId], !ancestors.isEmpty else { + guard let spaceService = session.spaceService, let ancestors = spaceService.ancestorsPerRoomId[currentRoomId], !ancestors.isEmpty, allowsRoomUpgrade || !roomUpgradeRequired else { self.accessItems = [ RoomAccessTypeChooserAccessItem(id: .private, isSelected: false, title: VectorL10n.private, detail: VectorL10n.roomAccessSettingsScreenPrivateMessage, badgeText: nil), RoomAccessTypeChooserAccessItem(id: .public, isSelected: false, title: VectorL10n.public, detail: VectorL10n.roomAccessSettingsScreenPublicMessage, badgeText: nil), diff --git a/RiotSwiftUI/Modules/Spaces/MatrixItemChooser/Service/MatrixSDK/MatrixItemChooserRoomRestrictedAllowedParentsDataSource.swift b/RiotSwiftUI/Modules/Spaces/MatrixItemChooser/Service/MatrixSDK/MatrixItemChooserRoomRestrictedAllowedParentsDataSource.swift index df3018abb..db31241b5 100644 --- a/RiotSwiftUI/Modules/Spaces/MatrixItemChooser/Service/MatrixSDK/MatrixItemChooserRoomRestrictedAllowedParentsDataSource.swift +++ b/RiotSwiftUI/Modules/Spaces/MatrixItemChooser/Service/MatrixSDK/MatrixItemChooserRoomRestrictedAllowedParentsDataSource.swift @@ -37,13 +37,13 @@ class MatrixItemChooserRoomRestrictedAllowedParentsDataSource: MatrixItemChooser guard let self = self else { return } let joinRuleEvent = state?.stateEvents(with: .roomJoinRules)?.last - let allowContent: [[String: String]] = joinRuleEvent?.wireContent["allow"] as? [[String: String]] ?? [] + let allowContent: [[String: String]] = joinRuleEvent?.wireContent[kMXJoinRulesContentKeyAllow] as? [[String: String]] ?? [] self.allowedParentIds = allowContent.compactMap { allowDictionnary in - guard let type = allowDictionnary["type"], type == "m.room_membership" else { + guard let type = allowDictionnary[kMXJoinRulesContentKeyType], type == kMXEventTypeStringRoomMembership else { return nil } - return allowDictionnary["room_id"] + return allowDictionnary[kMXJoinRulesContentKeyRoomId] } let ancestorsId = session.spaceService.ancestorsPerRoomId[self.roomId] ?? [] diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Model/SpaceCreationSettingsAddressValidationStatus.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Model/SpaceCreationSettingsAddressValidationStatus.swift index 329c151e3..b7262a430 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Model/SpaceCreationSettingsAddressValidationStatus.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Model/SpaceCreationSettingsAddressValidationStatus.swift @@ -18,7 +18,32 @@ import Foundation enum SpaceCreationSettingsAddressValidationStatus { case none(_ address: String) + case current(_ address: String) case valid(_ address: String) case alreadyExists(_ address: String) case invalidCharacters(_ address: String) + + var message: String { + switch self { + case .none(let fullAddress): + return VectorL10n.spacesCreationAddressDefaultMessage(fullAddress) + case .current(let fullAddress): + return VectorL10n.spaceSettingsCurrentAddressMessage(fullAddress) + case .valid(let fullAddress): + return VectorL10n.spacesCreationAddressDefaultMessage(fullAddress) + case .alreadyExists(let fullAddress): + return VectorL10n.spacesCreationAddressAlreadyExists(fullAddress) + case .invalidCharacters(let fullAddress): + return VectorL10n.spacesCreationAddressInvalidCharacters(fullAddress) + } + } + + var isValid: Bool { + switch self { + case .none, .current, .valid: + return true + default: + return false + } + } } diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Service/MatrixSDK/SpaceCreationSettingsService.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Service/MatrixSDK/SpaceCreationSettingsService.swift index 98e7b573f..0b6e69450 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Service/MatrixSDK/SpaceCreationSettingsService.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Service/MatrixSDK/SpaceCreationSettingsService.swift @@ -94,7 +94,7 @@ class SpaceCreationSettingsService: SpaceCreationSettingsServiceProtocol { } private func updateDefaultAddress() { - defaultAddress = roomName.toValidAliasLocalPart() + defaultAddress = MXTools.validAliasLocalPart(from: roomName) } private func validateAddress() { @@ -102,7 +102,7 @@ class SpaceCreationSettingsService: SpaceCreationSettingsServiceProtocol { currentOperation = nil guard let userDefinedAddress = self.userDefinedAddress, !userDefinedAddress.isEmpty else { - let fullAddress = defaultAddress.fullLocalAlias(with: session) + let fullAddress = MXTools.fullLocalAlias(from: defaultAddress, with: session) if defaultAddress.isEmpty { addressValidationSubject.send(.none(fullAddress)) @@ -116,7 +116,7 @@ class SpaceCreationSettingsService: SpaceCreationSettingsServiceProtocol { } private func validate(_ aliasLocalPart: String) { - let fullAddress = aliasLocalPart.fullLocalAlias(with: session) + let fullAddress = MXTools.fullLocalAlias(from: aliasLocalPart, with: session) currentOperation = MXRoomAliasAvailabilityChecker.validate(aliasLocalPart: aliasLocalPart, with: session) { [weak self] result in guard let self = self else { return } diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/ViewModel/SpaceCreationSettingsViewModel.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/ViewModel/SpaceCreationSettingsViewModel.swift index c05e5d24a..de335ae45 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/ViewModel/SpaceCreationSettingsViewModel.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/ViewModel/SpaceCreationSettingsViewModel.swift @@ -76,8 +76,8 @@ class SpaceCreationSettingsViewModel: SpaceCreationSettingsViewModelType, SpaceC showRoomAddress: creationParameters.showAddress, defaultAddress: creationParameters.address ?? "", roomNameError: nil, - addressMessage: addressMessage(with: validationStatus), - isAddressValid: isAddressValid(with: validationStatus), + addressMessage: validationStatus.message, + isAddressValid: validationStatus.isValid, avatar: AvatarInput(mxContentUri: nil, matrixItemId: "", displayName: nil), avatarImage: creationParameters.userSelectedAvatar, bindings: bindings) @@ -120,8 +120,8 @@ class SpaceCreationSettingsViewModel: SpaceCreationSettingsViewModelType, SpaceC case .updateRoomDefaultAddress(let defaultAddress): state.defaultAddress = defaultAddress case .updateAddressValidationStatus(let validationStatus): - state.addressMessage = Self.addressMessage(with: validationStatus) - state.isAddressValid = Self.isAddressValid(with: validationStatus) + state.addressMessage = validationStatus.message + state.isAddressValid = validationStatus.isValid case .updateAvatar(let avatar): state.avatar = avatar case .updateAvatarImage(let image): @@ -161,26 +161,4 @@ class SpaceCreationSettingsViewModel: SpaceCreationSettingsViewModelType, SpaceC private func pickImage(from sourceRect: CGRect) { callback?(.pickImage(sourceRect)) } - - private static func addressMessage(with validationStatus: SpaceCreationSettingsAddressValidationStatus) -> String { - switch validationStatus { - case .none(let fullAddress): - return VectorL10n.spacesCreationAddressDefaultMessage(fullAddress) - case .valid(let fullAddress): - return VectorL10n.spacesCreationAddressDefaultMessage(fullAddress) - case .alreadyExists(let fullAddress): - return VectorL10n.spacesCreationAddressAlreadyExists(fullAddress) - case .invalidCharacters(let fullAddress): - return VectorL10n.spacesCreationAddressInvalidCharacters(fullAddress) - } - } - - private static func isAddressValid(with validationStatus: SpaceCreationSettingsAddressValidationStatus) -> Bool { - switch validationStatus { - case .none, .valid: - return true - default: - return false - } - } } diff --git a/RiotSwiftUI/Modules/Spaces/SpaceSettings/Coordinator/SpaceSettingsModalCoordinator.swift b/RiotSwiftUI/Modules/Spaces/SpaceSettings/Coordinator/SpaceSettingsModalCoordinator.swift new file mode 100644 index 000000000..a9bd120c7 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceSettings/Coordinator/SpaceSettingsModalCoordinator.swift @@ -0,0 +1,183 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import UIKit + +/// Actions returned by the coordinator callback +enum SpaceSettingsModalCoordinatorAction { + case done(_ spaceId: String) + case cancel(_ spaceId: String) +} + +@objcMembers +@available(iOS 14.0, *) +final class SpaceSettingsModalCoordinator: Coordinator { + + // MARK: - Properties + + // MARK: Private + + private let parameters: SpaceSettingsModalCoordinatorParameters + private var upgradedRoomId: String? + private var currentRoomId: String { + upgradedRoomId ?? parameters.spaceId + } + + private var navigationRouter: NavigationRouterType { + return self.parameters.navigationRouter + } + + // MARK: Public + + // Must be used only internally + var childCoordinators: [Coordinator] = [] + + var callback: ((SpaceSettingsModalCoordinatorAction) -> Void)? + + // MARK: - Setup + + init(parameters: SpaceSettingsModalCoordinatorParameters) { + self.parameters = parameters + } + + // MARK: - Public + + + func start() { + MXLog.debug("[SpaceSettingsModalCoordinator] did start.") + let rootCoordinator = self.createSpaceSettingsCoordinator() + rootCoordinator.start() + + self.add(childCoordinator: rootCoordinator) + + if self.navigationRouter.modules.isEmpty == false { + self.navigationRouter.push(rootCoordinator, animated: true, popCompletion: { [weak self] in + self?.remove(childCoordinator: rootCoordinator) + }) + } else { + self.navigationRouter.setRootModule(rootCoordinator) { [weak self] in + self?.remove(childCoordinator: rootCoordinator) + } + } + } + + func toPresentable() -> UIViewController { + return self.navigationRouter.toPresentable() + } + + // MARK: - Private + + @available(iOS 14.0, *) + func pushScreen(with coordinator: Coordinator & Presentable) { + add(childCoordinator: coordinator) + + self.navigationRouter.push(coordinator, animated: true, popCompletion: { [weak self] in + self?.remove(childCoordinator: coordinator) + }) + + coordinator.start() + } + + private func createSpaceSettingsCoordinator() -> SpaceSettingsCoordinator { + let coordinator = SpaceSettingsCoordinator(parameters: SpaceSettingsCoordinatorParameters(session: parameters.session, spaceId: parameters.spaceId)) + coordinator.completion = { [weak self] result in + guard let self = self else { return } + + switch result { + case .cancel: + self.callback?(.cancel(self.currentRoomId)) + case .done: + self.callback?(.done(self.currentRoomId)) + case .optionScreen(let optionType): + self.pushOptionScreen(ofType: optionType) + } + } + return coordinator + } + + private func pushOptionScreen(ofType optionType: SpaceSettingsOptionType) { + switch optionType { + case .rooms: + exploreRooms(ofSpaceWithId: self.parameters.spaceId) + case .members: + showMembers(ofSpaceWithId: self.parameters.spaceId) + case .visibility: + showAccess(ofSpaceWithId: self.parameters.spaceId) + } + } + + private func exploreRooms(ofSpaceWithId spaceId: String) { + let coordinator = ExploreRoomCoordinator(session: parameters.session, spaceId: spaceId) + coordinator.delegate = self + add(childCoordinator: coordinator) + coordinator.start() + self.navigationRouter.present(coordinator.toPresentable(), animated: true) + } + + private func showMembers(ofSpaceWithId spaceId: String) { + let coordinator = SpaceMembersCoordinator(parameters: SpaceMembersCoordinatorParameters(userSessionsService: UserSessionsService.shared, session: parameters.session, spaceId: spaceId)) + coordinator.delegate = self + add(childCoordinator: coordinator) + coordinator.start() + self.navigationRouter.present(coordinator.toPresentable(), animated: true) + } + + private func showAccess(ofSpaceWithId spaceId: String) { + guard let room = parameters.session.room(withRoomId: spaceId) else { + return + } + // Needed more tests on synaose side before starting space upgrade implementation + let coordinator = RoomAccessCoordinator(parameters: RoomAccessCoordinatorParameters(room: room, allowsRoomUpgrade: false)) + coordinator.callback = { [weak self] result in + guard let self = self else { return } + + switch result { + case .cancel(let roomId), .done(let roomId): + if roomId != spaceId { + // TODO: room has been upgraded + self.upgradedRoomId = roomId + } + + self.navigationRouter.dismissModule(animated: true, completion: { + self.remove(childCoordinator: coordinator) + }) + } + } + add(childCoordinator: coordinator) + coordinator.start() + self.navigationRouter.present(coordinator.toPresentable(), animated: true) + } +} + +// MARK: - ExploreRoomCoordinatorDelegate +@available(iOS 14.0, *) +extension SpaceSettingsModalCoordinator: ExploreRoomCoordinatorDelegate { + func exploreRoomCoordinatorDidComplete(_ coordinator: ExploreRoomCoordinatorType) { + self.navigationRouter.dismissModule(animated: true, completion: { + self.remove(childCoordinator: coordinator) + }) + } +} + +// MARK: - SpaceMembersCoordinatorDelegate +@available(iOS 14.0, *) +extension SpaceSettingsModalCoordinator: SpaceMembersCoordinatorDelegate { + func spaceMembersCoordinatorDidCancel(_ coordinator: SpaceMembersCoordinatorType) { + self.navigationRouter.dismissModule(animated: true, completion: { + self.remove(childCoordinator: coordinator) + }) + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceSettings/Coordinator/SpaceSettingsModalCoordinatorBridgePresenter.swift b/RiotSwiftUI/Modules/Spaces/SpaceSettings/Coordinator/SpaceSettingsModalCoordinatorBridgePresenter.swift new file mode 100644 index 000000000..551c58585 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceSettings/Coordinator/SpaceSettingsModalCoordinatorBridgePresenter.swift @@ -0,0 +1,100 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import UIKit + +@available(iOS 14.0, *) +@objc protocol SpaceSettingsModalCoordinatorBridgePresenterDelegate { + func spaceSettingsModalCoordinatorBridgePresenterDelegateDidCancel(_ coordinatorBridgePresenter: SpaceSettingsModalCoordinatorBridgePresenter) + func spaceSettingsModalCoordinatorBridgePresenterDelegateDidFinish(_ coordinatorBridgePresenter: SpaceSettingsModalCoordinatorBridgePresenter) +} + +/// SpaceSettingsModalCoordinatorBridgePresenter enables to start SpaceSettingsModalCoordinator from a view controller. +/// This bridge is used while waiting for global usage of coordinator pattern. +/// It breaks the Coordinator abstraction and it has been introduced for Objective-C compatibility (mainly for integration in legacy view controllers). +/// Each bridge should be removed once the underlying Coordinator has been integrated by another Coordinator. +@objcMembers +@available(iOS 14.0, *) +final class SpaceSettingsModalCoordinatorBridgePresenter: NSObject { + + // MARK: - Properties + + // MARK: Private + + private let spaceId: String + private let session: MXSession + private var coordinator: SpaceSettingsModalCoordinator? + + // MARK: Public + + weak var delegate: SpaceSettingsModalCoordinatorBridgePresenterDelegate? + + // MARK: - Setup + + init(spaceId: String, session: MXSession) { + self.spaceId = spaceId + self.session = session + super.init() + } + + // MARK: - Public + + func present(from viewController: UIViewController, animated: Bool) { + let navigationRouter = NavigationRouter() + let coordinator = SpaceSettingsModalCoordinator(parameters: SpaceSettingsModalCoordinatorParameters(session: session, spaceId: spaceId, navigationRouter: navigationRouter)) + coordinator.callback = { [weak self] result in + guard let self = self else { return } + + switch result { + case .cancel: + self.delegate?.spaceSettingsModalCoordinatorBridgePresenterDelegateDidCancel(self) + case .done: + self.delegate?.spaceSettingsModalCoordinatorBridgePresenterDelegateDidFinish(self) + } + } + let presentable = coordinator.toPresentable() + presentable.presentationController?.delegate = self + navigationRouter.setRootModule(presentable) + viewController.present(navigationRouter.toPresentable(), animated: animated, completion: nil) + coordinator.start() + + self.coordinator = coordinator + } + + func dismiss(animated: Bool, completion: (() -> Void)?) { + guard let coordinator = self.coordinator else { + return + } + coordinator.toPresentable().dismiss(animated: animated) { + self.coordinator = nil + + if let completion = completion { + completion() + } + } + } +} + +// MARK: - UIAdaptivePresentationControllerDelegate + +@available(iOS 14.0, *) +extension SpaceSettingsModalCoordinatorBridgePresenter: UIAdaptivePresentationControllerDelegate { + + func roomNotificationSettingsCoordinatorDidComplete(_ presentationController: UIPresentationController) { + self.delegate?.spaceSettingsModalCoordinatorBridgePresenterDelegateDidCancel(self) + } + +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceSettings/Coordinator/SpaceSettingsModalCoordinatorParameters.swift b/RiotSwiftUI/Modules/Spaces/SpaceSettings/Coordinator/SpaceSettingsModalCoordinatorParameters.swift new file mode 100644 index 000000000..ebe209018 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceSettings/Coordinator/SpaceSettingsModalCoordinatorParameters.swift @@ -0,0 +1,38 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +/// SpaceSettingsModalCoordinator input parameters +struct SpaceSettingsModalCoordinatorParameters { + + /// The Matrix session + let session: MXSession + + /// The ID of the space + let spaceId: String + + /// The navigation router that manage physical navigation + let navigationRouter: NavigationRouterType + + init(session: MXSession, + spaceId: String, + navigationRouter: NavigationRouterType? = nil) { + self.session = session + self.spaceId = spaceId + self.navigationRouter = navigationRouter ?? NavigationRouter(navigationController: RiotNavigationController()) + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceSettings/SpaceSettings/Coordinator/SpaceSettingsCoordinator.swift b/RiotSwiftUI/Modules/Spaces/SpaceSettings/SpaceSettings/Coordinator/SpaceSettingsCoordinator.swift new file mode 100644 index 000000000..5f1949f12 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceSettings/SpaceSettings/Coordinator/SpaceSettingsCoordinator.swift @@ -0,0 +1,101 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +struct SpaceSettingsCoordinatorParameters { + let session: MXSession + let spaceId: String +} + +final class SpaceSettingsCoordinator: Coordinator, Presentable { + + // MARK: - Properties + + // MARK: Private + + private let parameters: SpaceSettingsCoordinatorParameters + private let spaceSettingsHostingController: UIViewController + private var spaceSettingsViewModel: SpaceSettingsViewModelProtocol + + private lazy var singleImagePickerPresenter: SingleImagePickerPresenter = { + let presenter = SingleImagePickerPresenter(session: parameters.session) + presenter.delegate = self + return presenter + }() + + // MARK: Public + + // Must be used only internally + var childCoordinators: [Coordinator] = [] + var completion: ((SpaceSettingsCoordinatorResult) -> Void)? + + // MARK: - Setup + + @available(iOS 14.0, *) + init(parameters: SpaceSettingsCoordinatorParameters) { + self.parameters = parameters + let viewModel = SpaceSettingsViewModel.makeSpaceSettingsViewModel(service: SpaceSettingsService(session: parameters.session, spaceId: parameters.spaceId)) + let view = SpaceSettings(viewModel: viewModel.context) + .addDependency(AvatarService.instantiate(mediaManager: parameters.session.mediaManager)) + spaceSettingsViewModel = viewModel + spaceSettingsHostingController = VectorHostingController(rootView: view) + } + + // MARK: - Public + + func start() { + MXLog.debug("[SpaceSettingsCoordinator] did start.") + spaceSettingsViewModel.completion = { [weak self] result in + MXLog.debug("[SpaceSettingsCoordinator] SpaceSettingsViewModel did complete with result: \(result).") + guard let self = self else { return } + switch result { + case .cancel: + self.completion?(.cancel) + case .done: + self.completion?(.done) + case .optionScreen(let optionType): + self.completion?(.optionScreen(optionType)) + case .pickImage(let sourceRect): + self.pickImage(from: sourceRect) + } + } + } + + func toPresentable() -> UIViewController { + return self.spaceSettingsHostingController + + } + // MARK: - Private + + private func pickImage(from sourceRect: CGRect) { + let controller = toPresentable() + let adjustedRect = controller.view.convert(sourceRect, from: nil) + singleImagePickerPresenter.present(from: controller, sourceView: controller.view, sourceRect: adjustedRect, animated: true) + } +} + +// MARK: - SingleImagePickerPresenterDelegate +extension SpaceSettingsCoordinator: SingleImagePickerPresenterDelegate { + func singleImagePickerPresenter(_ presenter: SingleImagePickerPresenter, didSelectImageData imageData: Data, withUTI uti: MXKUTI?) { + spaceSettingsViewModel.updateAvatarImage(with: UIImage(data: imageData)) + presenter.dismiss(animated: true, completion: nil) + } + + func singleImagePickerPresenterDidCancel(_ presenter: SingleImagePickerPresenter) { + presenter.dismiss(animated: true, completion: nil) + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceSettings/SpaceSettings/MockSpaceSettingsScreenState.swift b/RiotSwiftUI/Modules/Spaces/SpaceSettings/SpaceSettings/MockSpaceSettingsScreenState.swift new file mode 100644 index 000000000..977ef8d81 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceSettings/SpaceSettings/MockSpaceSettingsScreenState.swift @@ -0,0 +1,84 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import SwiftUI + +/// Using an enum for the screen allows you define the different state cases with +/// the relevant associated data for each case. +@available(iOS 14.0, *) +enum MockSpaceSettingsScreenState: MockScreenState, CaseIterable { + // A case for each state you want to represent + // with specific, minimal associated data that will allow you + // mock that screen. + case visibility(SpaceSettingsVisibility) + case notEditable + + /// The associated screen + var screenType: Any.Type { + SpaceSettings.self + } + + /// A list of screen state definitions + static var allCases: [MockSpaceSettingsScreenState] { + SpaceSettingsVisibility.allCases.map(MockSpaceSettingsScreenState.visibility) + + [.notEditable] + } + + /// Generate the view struct for the screen state. + var screenView: ([Any], AnyView) { + let service: MockSpaceSettingsService + switch self { + case .visibility(let visibility): + let roomProperties = SpaceSettingsRoomProperties( + name: "Space Name", + topic: "Sapce topic", + address: nil, + avatarUrl: nil, + visibility: visibility, + allowedParentIds: [], + isAvatarEditable: true, + isNameEditable: true, + isTopicEditable: true, + isAddressEditable: true, + isAccessEditable: true) + service = MockSpaceSettingsService(roomProperties: roomProperties) + case .notEditable: + let roomProperties = SpaceSettingsRoomProperties( + name: "Space Name", + topic: "Sapce topic", + address: nil, + avatarUrl: nil, + visibility: .public, + allowedParentIds: [], + isAvatarEditable: false, + isNameEditable: false, + isTopicEditable: false, + isAddressEditable: false, + isAccessEditable: false) + service = MockSpaceSettingsService(roomProperties: roomProperties) + } + let viewModel = SpaceSettingsViewModel.makeSpaceSettingsViewModel(service: service) + + // can simulate service and viewModel actions here if needs be. + + return ( + [service, viewModel], + AnyView(SpaceSettings(viewModel: viewModel.context) + .addDependency(MockAvatarService.example)) + ) + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceSettings/SpaceSettings/Service/MatrixSDK/SpaceSettingsService.swift b/RiotSwiftUI/Modules/Spaces/SpaceSettings/SpaceSettings/Service/MatrixSDK/SpaceSettingsService.swift new file mode 100644 index 000000000..bec782b3a --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceSettings/SpaceSettings/Service/MatrixSDK/SpaceSettingsService.swift @@ -0,0 +1,474 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import Combine +import MatrixSDK + +@available(iOS 14.0, *) +class SpaceSettingsService: SpaceSettingsServiceProtocol { + + // MARK: - Properties + + var userDefinedAddress: String? { + didSet { + validateAddress() + } + } + + // MARK: Private + + private let session: MXSession + private var roomState: MXRoomState? { + didSet { + updateRoomProperties() + } + } + private let room: MXRoom? + private var roomEventListener: Any? + + private var publicAddress: String? { + didSet { + validateAddress() + } + } + + private var defaultAddress: String { + didSet { + validateAddress() + } + } + + // MARK: Public + + var displayName: String? { + room?.displayName + } + + private(set) var spaceId: String + private(set) var roomProperties: SpaceSettingsRoomProperties? { + didSet { + roomPropertiesSubject.send(roomProperties) + } + } + + private(set) var isLoadingSubject: CurrentValueSubject + private(set) var roomPropertiesSubject: CurrentValueSubject + private(set) var showPostProcessAlert: CurrentValueSubject + + private(set) var addressValidationSubject: CurrentValueSubject + var isAddressValid: Bool { + switch addressValidationSubject.value { + case .none, .valid: + return true + default: + return false + } + } + + private var currentOperation: MXHTTPOperation? + private var addressValidationOperation: MXHTTPOperation? + + private lazy var mediaUploader: MXMediaLoader = MXMediaManager.prepareUploader(withMatrixSession: session, initialRange: 0, andRange: 1.0) + + // MARK: - Setup + + init(session: MXSession, spaceId: String) { + self.session = session + self.spaceId = spaceId + self.room = session.room(withRoomId: spaceId) + self.isLoadingSubject = CurrentValueSubject(false) + self.showPostProcessAlert = CurrentValueSubject(false) + self.roomPropertiesSubject = CurrentValueSubject(self.roomProperties) + self.addressValidationSubject = CurrentValueSubject(.none("#")) + self.defaultAddress = "" + + readRoomState() + } + + deinit { + if let roomEventListener = self.roomEventListener { + self.room?.removeListener(roomEventListener) + } + + currentOperation?.cancel() + addressValidationOperation?.cancel() + } + + // MARK: - Public + + func addressDidChange(_ newValue: String) { + userDefinedAddress = newValue + } + + // MARK: - Private + + private func readRoomState() { + isLoadingSubject.send(true) + self.room?.state { [weak self] roomState in + self?.roomState = roomState + self?.isLoadingSubject.send(false) + } + + roomEventListener = self.room?.listen(toEvents: { [weak self] event, direction, state in + self?.room?.state({ [weak self] roomState in + self?.roomState = roomState + }) + }) + } + + private func visibility(with roomState: MXRoomState) -> SpaceSettingsVisibility { + switch roomState.joinRule { + case .public: + return .public + case .restricted: + return .restricted + default: + return .private + } + } + + private func allowedParentIds(with roomState: MXRoomState) -> [String] { + var allowedParentIds: [String] = [] + if roomState.joinRule == .restricted, let joinRuleEvent = roomState.stateEvents(with: .roomJoinRules)?.last { + let allowContent: [[String: String]] = joinRuleEvent.wireContent[kMXJoinRulesContentKeyAllow] as? [[String: String]] ?? [] + allowedParentIds = allowContent.compactMap { allowDictionnary in + guard let type = allowDictionnary[kMXJoinRulesContentKeyType], type == kMXEventTypeStringRoomMembership else { + return nil + } + + return allowDictionnary[kMXJoinRulesContentKeyRoomId] + } + } + return allowedParentIds + } + + private func isField(ofType notification: String, editableWith powerLevels: MXRoomPowerLevels?) -> Bool { + guard let powerLevels = powerLevels else { + return false + } + + let userPowerLevel = powerLevels.powerLevelOfUser(withUserID: self.session.myUserId) + return userPowerLevel >= powerLevels.minimumPowerLevel(forNotifications: notification, defaultPower: powerLevels.stateDefault) + } + + private func validateAddress() { + addressValidationOperation?.cancel() + addressValidationOperation = nil + + guard let userDefinedAddress = self.userDefinedAddress, !userDefinedAddress.isEmpty else { + let fullAddress = MXTools.fullLocalAlias(from: defaultAddress, with: session) + + if let publicAddress = self.publicAddress, !publicAddress.isEmpty { + addressValidationSubject.send(.current(fullAddress)) + } else if defaultAddress.isEmpty { + addressValidationSubject.send(.none(fullAddress)) + } else { + validate(defaultAddress) + } + return + } + + validate(userDefinedAddress) + } + + private func validate(_ aliasLocalPart: String) { + let fullAddress = MXTools.fullLocalAlias(from: aliasLocalPart, with: session) + + if let publicAddress = self.publicAddress, publicAddress == aliasLocalPart { + self.addressValidationSubject.send(.current(fullAddress)) + return + } + + addressValidationOperation = MXRoomAliasAvailabilityChecker.validate(aliasLocalPart: aliasLocalPart, with: session) { [weak self] result in + guard let self = self else { return } + + self.addressValidationOperation = nil + + switch result { + case .available: + self.addressValidationSubject.send(.valid(fullAddress)) + case .invalid: + self.addressValidationSubject.send(.invalidCharacters(fullAddress)) + case .notAvailable: + self.addressValidationSubject.send(.alreadyExists(fullAddress)) + case .serverError: + self.addressValidationSubject.send(.none(fullAddress)) + } + } + } + + private func updateRoomProperties() { + guard let roomState = roomState else { + return + } + + if let canonicalAlias = roomState.canonicalAlias { + let localAliasPart = MXTools.extractLocalAliasPart(from: canonicalAlias) + self.publicAddress = localAliasPart + self.defaultAddress = localAliasPart + } else { + self.publicAddress = nil + self.defaultAddress = MXTools.validAliasLocalPart(from: roomState.name) + } + + self.roomProperties = SpaceSettingsRoomProperties( + name: roomState.name, + topic: roomState.topic, + address: self.defaultAddress, + avatarUrl: roomState.avatar, + visibility: visibility(with: roomState), + allowedParentIds: allowedParentIds(with: roomState), + isAvatarEditable: isField(ofType: kMXEventTypeStringRoomAvatar, editableWith: roomState.powerLevels), + isNameEditable: isField(ofType: kMXEventTypeStringRoomName, editableWith: roomState.powerLevels), + isTopicEditable: isField(ofType: kMXEventTypeStringRoomTopic, editableWith: roomState.powerLevels), + isAddressEditable: isField(ofType: kMXEventTypeStringRoomAliases, editableWith: roomState.powerLevels), + isAccessEditable: isField(ofType: kMXEventTypeStringRoomJoinRules, editableWith: roomState.powerLevels)) + } + + // MARK: - Post process + + private var currentTaskIndex: Int = 0 + private var tasks: [PostProcessTask] = [] + private var lastError: Error? + private var completion: ((_ result: SpaceSettingsServiceCompletionResult) -> Void)? + + private enum PostProcessTaskType: Equatable { + case updateName(String) + case updateTopic(String) + case updateAlias(String) + case uploadAvatar(UIImage) + } + + private enum PostProcessTaskState: CaseIterable, Equatable { + case none + case started + case success + case failure + } + + private struct PostProcessTask: Equatable { + let type: PostProcessTaskType + var state: PostProcessTaskState = .none + var isFinished: Bool { + return state == .failure || state == .success + } + + static func == (lhs: PostProcessTask, rhs: PostProcessTask) -> Bool { + return lhs.type == rhs.type && lhs.state == rhs.state + } + } + + func update(roomName: String, topic: String, address: String, avatar: UIImage?, + completion: ((_ result: SpaceSettingsServiceCompletionResult) -> Void)?) { + // First attempt + if self.tasks.isEmpty { + var tasks: [PostProcessTask] = [] + if roomProperties?.name ?? "" != roomName { + tasks.append(PostProcessTask(type: .updateName(roomName))) + } + if roomProperties?.topic ?? "" != topic { + tasks.append(PostProcessTask(type: .updateTopic(topic))) + } + if roomProperties?.address ?? "" != address { + tasks.append(PostProcessTask(type: .updateAlias(address))) + } + if let avatarImage = avatar { + tasks.append(PostProcessTask(type: .uploadAvatar(avatarImage))) + } + self.tasks = tasks + } else { + // Retry -> restart failed tasks + self.tasks = tasks.map({ task in + if task.state == .failure { + return PostProcessTask(type: task.type, state: .none) + } + return task + }) + } + + self.isLoadingSubject.send(true) + self.completion = completion + self.lastError = nil + currentTaskIndex = -1 + runNextTask() + } + + private func runNextTask() { + currentTaskIndex += 1 + guard currentTaskIndex < tasks.count else { + self.isLoadingSubject.send(false) + if let error = lastError { + showPostProcessAlert.send(true) + completion?(.failure(error)) + } else { + completion?(.success) + } + return + } + + let task = tasks[currentTaskIndex] + + guard !task.isFinished else { + runNextTask() + return + } + + switch task.type { + case .updateName(let roomName): + update(roomName: roomName) + case .updateTopic(let topic): + update(topic: topic) + case .updateAlias(let address): + update(canonicalAlias: address) + case .uploadAvatar(let image): + upload(avatar: image) + } + } + + private func updateCurrentTaskState(with state: PostProcessTaskState) { + guard currentTaskIndex < tasks.count else { + return + } + + tasks[currentTaskIndex].state = state + } + + private func update(roomName: String) { + updateCurrentTaskState(with: .started) + + currentOperation = room?.setName(roomName, completion: { [weak self] response in + guard let self = self else { return } + + switch response { + case .success: + self.updateCurrentTaskState(with: .success) + case .failure(let error): + self.lastError = error + self.updateCurrentTaskState(with: .failure) + } + + self.runNextTask() + }) + } + + private func update(topic: String) { + updateCurrentTaskState(with: .started) + + currentOperation = room?.setTopic(topic, completion: { [weak self] response in + guard let self = self else { return } + + switch response { + case .success: + self.updateCurrentTaskState(with: .success) + case .failure(let error): + self.lastError = error + self.updateCurrentTaskState(with: .failure) + } + + self.runNextTask() + }) + } + + private func update(canonicalAlias: String) { + updateCurrentTaskState(with: .started) + + currentOperation = room?.addAlias(MXTools.fullLocalAlias(from: canonicalAlias, with: session), completion: { [weak self] response in + guard let self = self else { return } + + switch response { + case .success: + if let publicAddress = self.publicAddress { + self.currentOperation = self.room?.removeAlias(MXTools.fullLocalAlias(from: publicAddress, with: self.session), completion: { [weak self] response in + guard let self = self else { return } + + self.setup(canonicalAlias: canonicalAlias) + }) + } else { + self.setup(canonicalAlias: canonicalAlias) + } + case .failure(let error): + self.lastError = error + self.updateCurrentTaskState(with: .failure) + self.runNextTask() + } + }) + } + + private func setup(canonicalAlias: String) { + currentOperation = room?.setCanonicalAlias(MXTools.fullLocalAlias(from: canonicalAlias, with: session), completion: { [weak self] response in + guard let self = self else { return } + + switch response { + case .success: + self.updateCurrentTaskState(with: .success) + case .failure(let error): + self.lastError = error + self.updateCurrentTaskState(with: .failure) + } + + self.runNextTask() + }) + } + + private func upload(avatar: UIImage) { + updateCurrentTaskState(with: .started) + + let avatarUp = MXKTools.forceImageOrientationUp(avatar) + + mediaUploader.uploadData(avatarUp?.jpegData(compressionQuality: 0.5), filename: nil, mimeType: "image/jpeg", + success: { [weak self] (urlString) in + guard let self = self else { return } + + guard let urlString = urlString else { + self.updateCurrentTaskState(with: .failure) + self.runNextTask() + return + } + guard let url = URL(string: urlString) else { + self.updateCurrentTaskState(with: .failure) + self.runNextTask() + return + } + + self.setAvatar(withURL: url) + }, + failure: { [weak self] (error) in + guard let self = self else { return } + + self.lastError = error + self.updateCurrentTaskState(with: .failure) + self.runNextTask() + }) + } + + private func setAvatar(withURL url: URL) { + currentOperation = room?.setAvatar(url: url) { [weak self] (response) in + guard let self = self else { return } + + switch response { + case .success: + self.updateCurrentTaskState(with: .success) + case .failure(let error): + self.lastError = error + self.updateCurrentTaskState(with: .failure) + } + + self.runNextTask() + } + } + +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceSettings/SpaceSettings/Service/Mock/MockSpaceSettingsService.swift b/RiotSwiftUI/Modules/Spaces/SpaceSettings/SpaceSettings/Service/Mock/MockSpaceSettingsService.swift new file mode 100644 index 000000000..1ddf6ea08 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceSettings/SpaceSettings/Service/Mock/MockSpaceSettingsService.swift @@ -0,0 +1,56 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import Combine + +@available(iOS 14.0, *) +class MockSpaceSettingsService: SpaceSettingsServiceProtocol { + + var spaceId: String + var roomProperties: SpaceSettingsRoomProperties? + private(set) var displayName: String? + + var roomPropertiesSubject: CurrentValueSubject + private(set) var isLoadingSubject: CurrentValueSubject + private(set) var showPostProcessAlert: CurrentValueSubject + private(set) var addressValidationSubject: CurrentValueSubject + + init(spaceId: String = "!\(UUID().uuidString):matrix.org", + roomProperties: SpaceSettingsRoomProperties? = nil, + displayName: String? = nil, + isLoading: Bool = false, + showPostProcessAlert: Bool = false) { + self.spaceId = spaceId + self.roomProperties = roomProperties + self.displayName = displayName + self.isLoadingSubject = CurrentValueSubject(isLoading) + self.showPostProcessAlert = CurrentValueSubject(showPostProcessAlert) + self.roomPropertiesSubject = CurrentValueSubject(roomProperties) + self.addressValidationSubject = CurrentValueSubject(.none(spaceId)) + } + + func update(roomName: String, topic: String, address: String, avatar: UIImage?, completion: ((SpaceSettingsServiceCompletionResult) -> Void)?) { + } + + func addressDidChange(_ newValue: String) { + + } + + func simulateUpdate(addressValidationStatus: SpaceCreationSettingsAddressValidationStatus) { + self.addressValidationSubject.value = addressValidationStatus + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceSettings/SpaceSettings/Service/SpaceSettingsServiceProtocol.swift b/RiotSwiftUI/Modules/Spaces/SpaceSettings/SpaceSettings/Service/SpaceSettingsServiceProtocol.swift new file mode 100644 index 000000000..51775c8d6 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceSettings/SpaceSettings/Service/SpaceSettingsServiceProtocol.swift @@ -0,0 +1,49 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import Combine + +enum SpaceSettingsServiceCompletionResult { + case success + case failure(Error) +} + +@available(iOS 14.0, *) +protocol SpaceSettingsServiceProtocol: Avatarable { + var spaceId: String { get } + var roomProperties: SpaceSettingsRoomProperties? { get } + + var isLoadingSubject: CurrentValueSubject { get } + var roomPropertiesSubject: CurrentValueSubject { get } + var showPostProcessAlert: CurrentValueSubject { get } + var addressValidationSubject: CurrentValueSubject { get } + + func update(roomName: String, topic: String, address: String, avatar: UIImage?, completion: ((_ result: SpaceSettingsServiceCompletionResult) -> Void)?) + func addressDidChange(_ newValue: String) +} + +// MARK: Avatarable + +@available(iOS 14.0, *) +extension SpaceSettingsServiceProtocol { + var mxContentUri: String? { + roomProperties?.avatarUrl + } + var matrixItemId: String { + spaceId + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceSettings/SpaceSettings/SpaceSettingsModels.swift b/RiotSwiftUI/Modules/Spaces/SpaceSettings/SpaceSettings/SpaceSettingsModels.swift new file mode 100644 index 000000000..86e606c0e --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceSettings/SpaceSettings/SpaceSettingsModels.swift @@ -0,0 +1,123 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +// MARK: - Coordinator + +enum SpaceSettingsCoordinatorResult { + case cancel + case done + case optionScreen(_ optionType: SpaceSettingsOptionType) +} + +// MARK: View model + +enum SpaceSettingsViewModelResult { + case cancel + case done + case optionScreen(_ optionType: SpaceSettingsOptionType) + case pickImage(_ sourceRect: CGRect) +} + +// MARK: View + +enum SpaceSettingsVisibility: CaseIterable { + case `private` + case restricted + case `public` + + var stringValue: String { + switch self { + case .private: + return VectorL10n.private + case .public: + return VectorL10n.public + case .restricted: + return VectorL10n.createRoomTypeRestricted + } + } +} + +struct SpaceSettingsRoomProperties { + let name: String? + let topic: String? + let address: String? + let avatarUrl: String? + let visibility: SpaceSettingsVisibility + let allowedParentIds: [String] + let isAvatarEditable: Bool + let isNameEditable: Bool + let isTopicEditable: Bool + let isAddressEditable: Bool + let isAccessEditable: Bool +} + +struct SpaceSettingsViewState: BindableState { + let defaultAddress: String + let avatar: AvatarInputProtocol + var roomProperties: SpaceSettingsRoomProperties? + var userSelectedAvatar: UIImage? + var showRoomAddress: Bool + let roomNameError: String? + var addressMessage: String? + var isAddressValid: Bool + var isLoading: Bool + var visibilityString: String + var options: [SpaceSettingsOption] + var isModified: Bool { + userSelectedAvatar != nil || isRoomNameModified || isTopicModified || isAddressModified + } + var isRoomNameModified: Bool { + (roomProperties?.name ?? "") != bindings.name + } + var isTopicModified: Bool { + (roomProperties?.topic ?? "") != bindings.topic + } + var isAddressModified: Bool { + (roomProperties?.address ?? "") != bindings.address + } + var bindings: SpaceSettingsViewModelBindings +} + +struct SpaceSettingsViewModelBindings { + var name: String + var topic: String + var address: String + var showPostProcessAlert: Bool +} + +struct SpaceSettingsOption: Identifiable { + let id: SpaceSettingsOptionType + let icon: UIImage? + let title: String? + let value: String? + let isEnabled: Bool +} + +enum SpaceSettingsOptionType { + case visibility + case rooms + case members +} + +enum SpaceSettingsViewAction { + case done(_ name: String, _ topic: String, _ address: String, _ userSelectedAvatar: UIImage?) + case cancel + case pickImage(_ sourceRect: CGRect) + case optionSelected(_ optionType: SpaceSettingsOptionType) + case addressChanged(_ newValue: String) +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceSettings/SpaceSettings/SpaceSettingsViewModel.swift b/RiotSwiftUI/Modules/Spaces/SpaceSettings/SpaceSettings/SpaceSettingsViewModel.swift new file mode 100644 index 000000000..fc2771823 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceSettings/SpaceSettings/SpaceSettingsViewModel.swift @@ -0,0 +1,155 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI +import Combine + +@available(iOS 14, *) +typealias SpaceSettingsViewModelType = StateStoreViewModel +@available(iOS 14, *) +class SpaceSettingsViewModel: SpaceSettingsViewModelType, SpaceSettingsViewModelProtocol { + + // MARK: - Properties + + private static let options: [SpaceSettingsOption] = [ + SpaceSettingsOption(id: .rooms, icon: Asset.Images.spaceRoomIcon.image, title: VectorL10n.titleRooms, value: nil, isEnabled: true), + SpaceSettingsOption(id: .members, icon: Asset.Images.spaceMenuMembers.image, title: VectorL10n.roomDetailsPeople, value: nil, isEnabled: true) + ] + + // MARK: Private + + private let service: SpaceSettingsServiceProtocol + + // MARK: Public + + var completion: ((SpaceSettingsViewModelResult) -> Void)? + + // MARK: - Setup + + static func makeSpaceSettingsViewModel(service: SpaceSettingsServiceProtocol) -> SpaceSettingsViewModelProtocol { + return SpaceSettingsViewModel(service: service) + } + + private init(service: SpaceSettingsServiceProtocol) { + self.service = service + super.init(initialViewState: Self.defaultState(with: service, validationStatus: service.addressValidationSubject.value)) + setupObservers() + } + + private static func defaultState(with service: SpaceSettingsServiceProtocol, validationStatus: SpaceCreationSettingsAddressValidationStatus) -> SpaceSettingsViewState { + let bindings = SpaceSettingsViewModelBindings( + name: service.roomProperties?.name ?? "", + topic: service.roomProperties?.topic ?? "", + address: service.roomProperties?.address ?? "", + showPostProcessAlert: service.showPostProcessAlert.value) + + return SpaceSettingsViewState( + defaultAddress: service.roomProperties?.address ?? "", + avatar: AvatarInput(mxContentUri: service.mxContentUri, matrixItemId: service.matrixItemId, displayName: service.displayName), + roomProperties: service.roomProperties, + userSelectedAvatar: nil, + showRoomAddress: service.roomProperties?.visibility == .public, + roomNameError: nil, + addressMessage: validationStatus.message, + isAddressValid: validationStatus.isValid, + isLoading: service.isLoadingSubject.value, + visibilityString: (service.roomProperties?.visibility ?? .private).stringValue, + options: options, + bindings: bindings) + } + + private func setupObservers() { + service.isLoadingSubject.sink { [weak self] isLoading in + self?.state.isLoading = isLoading + } + .store(in: &cancellables) + + service.showPostProcessAlert.sink { [weak self] showPostProcessAlert in + self?.state.bindings.showPostProcessAlert = showPostProcessAlert + } + .store(in: &cancellables) + + service.roomPropertiesSubject.sink { [weak self] roomProperties in + guard let roomProperties = roomProperties, let self = self else { + return + } + + self.propertiesUpdated(roomProperties) + } + .store(in: &cancellables) + + service.addressValidationSubject.sink { [weak self] validationStatus in + self?.state.addressMessage = validationStatus.message + self?.state.isAddressValid = validationStatus.isValid + } + .store(in: &cancellables) + } + + // MARK: - Public + + override func process(viewAction: SpaceSettingsViewAction) { + switch viewAction { + case .cancel: + cancel() + case .pickImage(let sourceRect): + completion?(.pickImage(sourceRect)) + case .optionSelected(let optionType): + completion?(.optionScreen(optionType)) + case .done(let name, let topic, let address, let userSelectedAvatar): + service.update(roomName: name, topic: topic, address: address, avatar: userSelectedAvatar) { [weak self] result in + guard let self = self else { return } + + switch result { + case .success: + self.done() + case .failure: + break + } + } + case .addressChanged(let newValue): + service.addressDidChange(newValue) + } + } + + func updateAvatarImage(with image: UIImage?) { + state.userSelectedAvatar = image + } + + private func propertiesUpdated(_ roomProperties: SpaceSettingsRoomProperties) { + state.roomProperties = roomProperties + if !state.isRoomNameModified { + state.bindings.name = roomProperties.name ?? "" + } + if !state.isTopicModified { + state.bindings.topic = roomProperties.topic ?? "" + } + if !state.isAddressModified { + state.bindings.address = roomProperties.address ?? "" + } + state.visibilityString = roomProperties.visibility.stringValue + state.showRoomAddress = roomProperties.visibility == .public + } + + private func done() { + completion?(.done) + } + + private func cancel() { + completion?(.cancel) + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceSettings/SpaceSettings/SpaceSettingsViewModelProtocol.swift b/RiotSwiftUI/Modules/Spaces/SpaceSettings/SpaceSettings/SpaceSettingsViewModelProtocol.swift new file mode 100644 index 000000000..8c55ae90c --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceSettings/SpaceSettings/SpaceSettingsViewModelProtocol.swift @@ -0,0 +1,27 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +protocol SpaceSettingsViewModelProtocol { + + var completion: ((SpaceSettingsViewModelResult) -> Void)? { get set } + @available(iOS 14, *) + static func makeSpaceSettingsViewModel(service: SpaceSettingsServiceProtocol) -> SpaceSettingsViewModelProtocol + @available(iOS 14, *) + var context: SpaceSettingsViewModelType.Context { get } + func updateAvatarImage(with image: UIImage?) +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceSettings/SpaceSettings/Test/UI/SpaceSettingsUITests.swift b/RiotSwiftUI/Modules/Spaces/SpaceSettings/SpaceSettings/Test/UI/SpaceSettingsUITests.swift new file mode 100644 index 000000000..2e47c2f76 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceSettings/SpaceSettings/Test/UI/SpaceSettingsUITests.swift @@ -0,0 +1,35 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest +import RiotSwiftUI + +@available(iOS 14.0, *) +class SpaceSettingsUITests: MockScreenTest { + + override class var screenType: MockScreenState.Type { + return MockSpaceSettingsScreenState.self + } + + override class func createTest() -> MockScreenTest { + return SpaceSettingsUITests(selector: #selector(verifySpaceSettingsScreen)) + } + + func verifySpaceSettingsScreen() throws { + guard let screenState = screenState as? MockSpaceSettingsScreenState else { fatalError("no screen") } + } + +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceSettings/SpaceSettings/Test/Unit/SpaceSettingsViewModelTests.swift b/RiotSwiftUI/Modules/Spaces/SpaceSettings/SpaceSettings/Test/Unit/SpaceSettingsViewModelTests.swift new file mode 100644 index 000000000..2458e3f42 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceSettings/SpaceSettings/Test/Unit/SpaceSettingsViewModelTests.swift @@ -0,0 +1,60 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest +import Combine + +@testable import RiotSwiftUI + +@available(iOS 14.0, *) +class SpaceSettingsViewModelTests: XCTestCase { + let creationParameters = SpaceCreationParameters() + var service: MockSpaceSettingsService! + var viewModel: SpaceSettingsViewModelProtocol! + var context: SpaceSettingsViewModelType.Context! + var cancellables = Set() + + override func setUpWithError() throws { + let roomProperties = SpaceSettingsRoomProperties( + name: "Space Name", + topic: "Sapce topic", + address: "#fake:matrix.org", + avatarUrl: nil, + visibility: .public, + allowedParentIds: [], + isAvatarEditable: true, + isNameEditable: true, + isTopicEditable: true, + isAddressEditable: true, + isAccessEditable: true) + + service = MockSpaceSettingsService(roomProperties: roomProperties, displayName: roomProperties.name, isLoading: false, showPostProcessAlert: false) + viewModel = SpaceSettingsViewModel.makeSpaceSettingsViewModel(service: service) + context = viewModel.context + } + + func testAddressAlready() throws { + service.simulateUpdate(addressValidationStatus: .alreadyExists("#fake:matrix.org")) + XCTAssertEqual(context.viewState.isAddressValid, false) + XCTAssertEqual(context.viewState.addressMessage, VectorL10n.spacesCreationAddressAlreadyExists("#fake:matrix.org")) + } + + func testInvalidAddress() throws { + service.simulateUpdate(addressValidationStatus: .invalidCharacters("#fake:matrix.org")) + XCTAssertEqual(context.viewState.isAddressValid, false) + XCTAssertEqual(context.viewState.addressMessage, VectorL10n.spacesCreationAddressInvalidCharacters("#fake:matrix.org")) + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceSettings/SpaceSettings/View/SpaceSettings.swift b/RiotSwiftUI/Modules/Spaces/SpaceSettings/SpaceSettings/View/SpaceSettings.swift new file mode 100644 index 000000000..6332303f1 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceSettings/SpaceSettings/View/SpaceSettings.swift @@ -0,0 +1,206 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +@available(iOS 14.0, *) +struct SpaceSettings: View { + + // MARK: - Properties + + // MARK: Private + + @Environment(\.theme) private var theme: ThemeSwiftUI + + // MARK: Public + + @ObservedObject var viewModel: SpaceSettingsViewModel.Context + + var body: some View { + ScrollView { + VStack { + avatarView + Spacer().frame(height:32) + formView + roomAccess + options + .padding(.bottom, 32) + } + } + .background(theme.colors.navigation) + .waitOverlay(show: viewModel.viewState.isLoading, allowUserInteraction: false) + .ignoresSafeArea(.container, edges: .bottom) + .frame(maxHeight: .infinity) + .navigationBarBackButtonHidden(true) + .navigationTitle(VectorL10n.settingsTitle) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button(VectorL10n.done) { + updateSpace() + } + .disabled(!viewModel.viewState.isModified || !viewModel.viewState.isAddressValid) + } + ToolbarItem(placement: .cancellationAction) { + Button(VectorL10n.cancel) { + viewModel.send(viewAction: .cancel) + } + } + } + .accentColor(theme.colors.accent) + .alert(isPresented: $viewModel.showPostProcessAlert, content: { + Alert(title: Text(VectorL10n.settingsTitle), + message: Text(VectorL10n.spaceSettingsUpdateFailedMessage), + primaryButton: .default(Text(VectorL10n.retry), action: { + updateSpace() + }), + secondaryButton: .cancel()) + }) + } + + // MARK: - Private + + @ViewBuilder + private var avatarView: some View { + ZStack(alignment: .bottomTrailing) { + GeometryReader { reader in + ZStack { + SpaceAvatarImage(mxContentUri: viewModel.viewState.avatar.mxContentUri, matrixItemId: viewModel.viewState.avatar.matrixItemId, displayName: viewModel.viewState.avatar.displayName, size: .xxLarge) + .padding(6) + if let image = viewModel.viewState.userSelectedAvatar { + Image(uiImage: image) + .resizable() + .scaledToFill() + .frame(width: 80, height: 80, alignment: .center) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + }.padding(10) + .onTapGesture { + guard viewModel.viewState.roomProperties?.isAvatarEditable == true else { + return + } + ResponderManager.resignFirstResponder() + viewModel.send(viewAction: .pickImage(reader.frame(in: .global))) + } + } + if viewModel.viewState.roomProperties?.isAvatarEditable == true { + Image(uiImage: Asset.Images.spaceCreationCamera.image) + .renderingMode(.template) + .foregroundColor(theme.colors.secondaryContent) + .frame(width: 32, height: 32, alignment: .center) + .background(theme.colors.background) + .clipShape(Circle()) + } + }.frame(width: 104, height: 104) + } + + @ViewBuilder + private var formView: some View { + VStack{ + RoundedBorderTextField( + title: VectorL10n.createRoomPlaceholderName, + placeHolder: "", + text: $viewModel.name, + footerText: .constant(viewModel.viewState.roomNameError), + isError: .constant(true), + configuration: UIKitTextInputConfiguration( returnKeyType: .next)) + .padding(.horizontal, 2) + .padding(.bottom, 20) + .disabled(viewModel.viewState.roomProperties?.isNameEditable != true) + RoundedBorderTextEditor( + title: VectorL10n.spaceTopic, + placeHolder: VectorL10n.spaceTopic, + text: $viewModel.topic, + textMaxHeight: 72, + error: .constant(nil)) + .padding(.horizontal, 2) + .padding(.bottom, viewModel.viewState.showRoomAddress ? 20 : 3) + .disabled(viewModel.viewState.roomProperties?.isTopicEditable != true) + if viewModel.viewState.showRoomAddress { + RoundedBorderTextField( + title: VectorL10n.spacesCreationAddress, + placeHolder: "# \(viewModel.viewState.defaultAddress)", + text: $viewModel.address, + footerText: .constant(viewModel.viewState.addressMessage), + isError: .constant(!viewModel.viewState.isAddressValid), + configuration: UIKitTextInputConfiguration(keyboardType: .URL, returnKeyType: .done, autocapitalizationType: .none), onTextChanged: { + newText in + viewModel.send(viewAction: .addressChanged(newText)) + }) + .disabled(viewModel.viewState.roomProperties?.isAddressEditable != true) + .padding(.horizontal, 2) + .padding(.bottom, 3) + .accessibility(identifier: "addressTextField") + } + } + .padding(.horizontal) + } + + @ViewBuilder + private var roomAccess: some View { + VStack(alignment: .leading) { + Spacer().frame(height:24) + Text(VectorL10n.spaceSettingsAccessSection) + .font(theme.fonts.footnote) + .foregroundColor(theme.colors.secondaryContent) + .padding(.leading) + .padding(.bottom, 4) + SpaceSettingsOptionListItem( + title: VectorL10n.roomDetailsAccessRowTitle, + value: viewModel.viewState.visibilityString) { + ResponderManager.resignFirstResponder() + viewModel.send(viewAction: .optionSelected(.visibility)) + } + .disabled(viewModel.viewState.roomProperties?.isAccessEditable != true) + } + } + + @ViewBuilder + private var options: some View { + VStack(alignment: .leading, spacing: 1) { + Spacer().frame(height: 50) + Text(VectorL10n.settingsTitle.uppercased()) + .font(theme.fonts.footnote) + .foregroundColor(theme.colors.secondaryContent) + .padding(.leading) + .padding(.bottom, 8) + ForEach(viewModel.viewState.options) { option in + SpaceSettingsOptionListItem( + icon: option.icon, + title: option.title, + value: option.value) { + ResponderManager.resignFirstResponder() + viewModel.send(viewAction: .optionSelected(option.id)) + } + .disabled(!option.isEnabled) + } + } + } + + private func updateSpace() { + viewModel.send(viewAction: .done(viewModel.name, viewModel.topic, viewModel.address, viewModel.viewState.userSelectedAvatar)) + } +} + +// MARK: - Previews + +@available(iOS 14.0, *) +struct SpaceSettings_Previews: PreviewProvider { + static let stateRenderer = MockSpaceSettingsScreenState.stateRenderer + static var previews: some View { + stateRenderer.screenGroup() + stateRenderer.screenGroup().theme(.dark).preferredColorScheme(.dark) + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceSettings/SpaceSettings/View/SpaceSettingsOptionListItem.swift b/RiotSwiftUI/Modules/Spaces/SpaceSettings/SpaceSettings/View/SpaceSettingsOptionListItem.swift new file mode 100644 index 000000000..135f6f77d --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceSettings/SpaceSettings/View/SpaceSettingsOptionListItem.swift @@ -0,0 +1,106 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +@available(iOS 14.0, *) +struct SpaceSettingsOptionListItem: View { + + // MARK: Private + + @Environment(\.theme) private var theme: ThemeSwiftUI + @Environment(\.isEnabled) private var isEnabled + + // MARK: - Properties + + let icon: UIImage? + let title: String? + let value: String? + let action: (() -> Void)? + + // MARK: - Setup + + init(icon: UIImage? = nil, + title: String? = nil, + value: String? = nil, + action: (() -> Void)? = nil) { + self.icon = icon + self.title = title + self.value = value + self.action = action + } + + // MARK: - Public + + var body: some View { + ZStack { + HStack(alignment: .center, spacing: 16) { + if let icon = icon { + Image(uiImage: icon) + .renderingMode(.template) + .frame(width: 22, height: 22) + .foregroundColor(theme.colors.tertiaryContent) + } + if let title = title { + Text(title) + .font(theme.fonts.body) + .foregroundColor(theme.colors.primaryContent) + } + Spacer() + if let value = value { + Text(value) + .font(theme.fonts.body) + .foregroundColor(theme.colors.tertiaryContent) + } + Image(systemName: "chevron.right") + .renderingMode(.template) + .font(.system(size: 16, weight: .regular)) + .foregroundColor(theme.colors.quarterlyContent) + } + .opacity(isEnabled ? 1 : 0.5) + } + .frame(height: 44) + .padding(.horizontal) + .background(theme.colors.background) + .onTapGesture { + if isEnabled { + action?() + } + } + } +} + +// MARK: - Previews + +@available(iOS 14.0, *) +struct SpaceSettingsOptionListItem_Previews: PreviewProvider { + + static var previews: some View { + sampleView.theme(.light).preferredColorScheme(.light) + sampleView.theme(.dark).preferredColorScheme(.dark) + } + + static var sampleView: some View { + VStack(spacing: 8) { + SpaceSettingsOptionListItem(icon: nil, title: "Some Title", value: nil) + SpaceSettingsOptionListItem(icon: nil, title: "Some Title", value: "Some value") + SpaceSettingsOptionListItem(icon: Asset.Images.spaceRoomIcon.image, title: "Some Title", value: "Some value") + SpaceSettingsOptionListItem(icon: Asset.Images.spaceRoomIcon.image, title: "Some Title", value: "Some value") + .disabled(true) + } + } + +} diff --git a/changelog.d/5233.feature b/changelog.d/5233.feature new file mode 100644 index 000000000..6db021b34 --- /dev/null +++ b/changelog.d/5233.feature @@ -0,0 +1 @@ +Space Settings \ No newline at end of file