From fda0568d88c6caa4b694e3d067bfeac8d85167b7 Mon Sep 17 00:00:00 2001 From: Gil Eluard Date: Tue, 23 Nov 2021 09:35:32 +0100 Subject: [PATCH 001/165] [iOS] Create public space #143 - Initial space creation flow --- .../spaces_add_space.imageset/Contents.json | 23 ++ .../spaces_add_space.png | Bin 0 -> 166 bytes .../spaces_add_space@2x.png | Bin 0 -> 207 bytes .../spaces_add_space@3x.png | Bin 0 -> 246 bytes .../Contents.json | 23 ++ .../spaces_invite_users.png | Bin 0 -> 474 bytes .../spaces_invite_users@2x.png | Bin 0 -> 803 bytes .../spaces_invite_users@3x.png | Bin 0 -> 1102 bytes .../spaces_modal_close.imageset/Contents.json | 23 ++ .../spaces_modal_close.png | Bin 0 -> 363 bytes .../spaces_modal_close@2x.png | Bin 0 -> 552 bytes .../spaces_modal_close@3x.png | Bin 0 -> 720 bytes Riot/Assets/en.lproj/Vector.strings | 51 +++ Riot/Generated/Images.swift | 3 + Riot/Generated/Strings.swift | 168 +++++++++ Riot/Modules/Application/LegacyAppDelegate.m | 6 +- .../SwiftUI/VectorHostingController.swift | 9 + .../SideMenu/SideMenuCoordinator.swift | 40 ++- .../SpaceList/SpaceListCoordinator.swift | 5 + .../SpaceList/SpaceListCoordinatorType.swift | 1 + .../Spaces/SpaceList/SpaceListSection.swift | 1 + .../Spaces/SpaceList/SpaceListViewCell.swift | 4 +- .../SpaceList/SpaceListViewController.swift | 6 +- .../Spaces/SpaceList/SpaceListViewModel.swift | 48 ++- .../SpaceList/SpaceListViewModelType.swift | 1 + .../Common/Avatar/View/SpaceAvatarImage.swift | 109 ++++++ .../MatrixSDK/MXUserAvatarable.swift | 31 ++ .../Common/Util/ClearViewModifier.swift | 52 +++ .../Util/NavigationBarConfigurator.swift | 71 ++++ .../Common/Util/NextViewModifier.swift | 55 +++ .../Modules/Common/Util/OptionButton.swift | 90 +++++ .../Common/Util/ResponderManager.swift | 73 ++++ .../Common/Util/RoundedBorderTextEditor.swift | 106 ++++++ .../Common/Util/RoundedBorderTextField.swift | 109 ++++++ .../Modules/Common/Util/SearchBar.swift | 81 +++++ .../Modules/Common/Util/ThemableButton.swift | 82 +++++ .../Common/Util/ThemableTextEditor.swift | 147 ++++++++ .../Common/Util/ThemableTextField.swift | 138 ++++++++ .../Modules/Common/Util/View+Riot.swift | 28 ++ .../SpaceCreationCoordinator.swift | 242 +++++++++++++ .../SpaceCreationCoordinatorAction.swift | 22 ++ .../SpaceCreationCoordinatorParameters.swift | 40 +++ ...SpaceCreationEmailInvitesCoordinator.swift | 74 ++++ ...ionEmailInvitesCoordinatorParameters.swift | 24 ++ ...reationEmailInvitesCoordinatorAction.swift | 23 ++ .../SpaceCreationEmailInvitesPresence.swift | 44 +++ ...SpaceCreationEmailInvitesStateAction.swift | 23 ++ .../SpaceCreationEmailInvitesViewAction.swift | 25 ++ ...reationEmailInvitesViewModelBindings.swift | 21 ++ ...eCreationEmailInvitesViewModelResult.swift | 25 ++ .../SpaceCreationEmailInvitesViewState.swift | 25 ++ .../SpaceCreationEmailInvitesService.swift | 29 ++ ...SpaceCreationEmailInvitesScreenState.swift | 67 ++++ ...MockSpaceCreationEmailInvitesService.swift | 33 ++ ...eCreationEmailInvitesServiceProtocol.swift | 25 ++ .../UI/SpaceCreationEmailInvitesUITests.swift | 49 +++ ...ceCreationEmailInvitesViewModelTests.swift | 41 +++ .../View/SpaceCreationEmailInvites.swift | 124 +++++++ .../SpaceCreationEmailInvitesViewModel.swift | 95 +++++ ...reationEmailInvitesViewModelProtocol.swift | 26 ++ ...CreationMatrixItemChooserCoordinator.swift | 72 ++++ ...trixItemChooserCoordinatorParameters.swift | 25 ++ .../Model/SpaceCreationMatrixItem.swift | 26 ++ ...onMatrixItemChooserCoordinatorAction.swift | 23 ++ ...aceCreationMatrixItemListStateAction.swift | 23 ++ ...rixItemListStateActionListViewAction.swift | 25 ++ ...emListStateActionListViewModelAction.swift | 23 ++ ...trixItemListStateActionListViewState.swift | 27 ++ .../Model/SpaceCreationMatrixItemType.swift | 22 ++ ...paceCreationMatrixItemChooserService.swift | 110 ++++++ ...CreationMatrixItemChooserScreenState.swift | 59 ++++ ...paceCreationMatrixItemChooserService.swift | 66 ++++ ...tionMatrixItemChooserServiceProtocol.swift | 30 ++ ...paceCreationMatrixItemChooserUITests.swift | 70 ++++ ...ationMatrixItemChooserViewModelTests.swift | 53 +++ .../View/SpaceCreationMatrixItemChooser.swift | 136 ++++++++ ...paceCreationMatrixItemChooserListRow.swift | 73 ++++ ...ceCreationMatrixItemChooserViewModel.swift | 114 ++++++ ...onMatrixItemChooserViewModelProtocol.swift | 28 ++ .../SpaceCreationMenuCoordinator.swift | 73 ++++ ...aceCreationMenuCoordinatorParamaters.swift | 28 ++ .../SpaceCreationMenuCoordinatorAction.swift | 25 ++ .../Model/SpaceCreationMenuRoom.swift | 36 ++ .../Model/SpaceCreationMenuStateAction.swift | 23 ++ .../Model/SpaceCreationMenuViewAction.swift | 25 ++ .../SpaceCreationMenuViewModelAction.swift | 25 ++ .../Model/SpaceCreationMenuViewState.swift | 27 ++ .../Model/SpaceCreationParameters.swift | 56 +++ .../Test/UI/SpaceCreationMenuUITests.swift | 54 +++ .../SpaceCreationMenuViewModelTests.swift | 59 ++++ .../View/SpaceCreationMenu.swift | 150 ++++++++ .../SpaceCreationMenuViewModel.swift | 89 +++++ .../SpaceCreationMenuViewModelProtocol.swift | 25 ++ .../SpaceCreationPostProcessCoordinator.swift | 71 ++++ ...tionPostProcessCoordinatorParameters.swift | 24 ++ ...CreationPostProcessCoordinatorAction.swift | 22 ++ .../SpaceCreationPostProcessPresence.swift | 44 +++ .../SpaceCreationPostProcessStateAction.swift | 23 ++ .../Model/SpaceCreationPostProcessTask.swift | 44 +++ .../SpaceCreationPostProcessViewAction.swift | 25 ++ ...ceCreationPostProcessViewModelResult.swift | 24 ++ .../SpaceCreationPostProcessViewState.swift | 25 ++ .../SpaceCreationPostProcessService.swift | 330 ++++++++++++++++++ ...kSpaceCreationPostProcessScreenState.swift | 56 +++ .../MockSpaceCreationPostProcessService.swift | 50 +++ ...ceCreationPostProcessServiceProtocol.swift | 27 ++ .../UI/SpaceCreationPostProcessUITests.swift | 55 +++ ...aceCreationPostProcessViewModelTests.swift | 59 ++++ .../View/SpaceCreationPostProcess.swift | 83 +++++ .../View/SpaceCreationPostProcessItem.swift | 87 +++++ .../SpaceCreationPostProcessViewModel.swift | 140 ++++++++ ...CreationPostProcessViewModelProtocol.swift | 28 ++ .../SpaceCreationRoomsCoordinator.swift | 71 ++++ ...ceCreationRoomsCoordinatorParameters.swift | 24 ++ .../SpaceCreationRoomsCoordinatorAction.swift | 22 ++ .../Model/SpaceCreationRoomsPresence.swift | 44 +++ .../Model/SpaceCreationRoomsStateAction.swift | 22 ++ .../Model/SpaceCreationRoomsViewAction.swift | 24 ++ .../SpaceCreationRoomsViewModelBindings.swift | 22 ++ .../SpaceCreationRoomsViewModelResult.swift | 24 ++ .../Model/SpaceCreationRoomsViewState.swift | 24 ++ .../MockSpaceCreationRoomsScreenState.swift | 62 ++++ .../Test/UI/SpaceCreationRoomsUITests.swift | 47 +++ .../SpaceCreationRoomsViewModelTests.swift | 39 +++ .../View/SpaceCreationRooms.swift | 101 ++++++ .../SpaceCreationRoomsViewModel.swift | 79 +++++ .../SpaceCreationRoomsViewModelProtocol.swift | 26 ++ .../SpaceCreationSettingsCoordinator.swift | 102 ++++++ ...reationSettingsCoordinatorParamaters.swift | 24 ++ ...ationSettingsAddressValidationStatus.swift | 24 ++ ...aceCreationSettingsCoordinatorAction.swift | 22 ++ .../SpaceCreationSettingsStateAction.swift | 29 ++ .../SpaceCreationSettingsViewAction.swift | 30 ++ ...SpaceCreationSettingsViewModelAction.swift | 27 ++ ...aceCreationSettingsViewModelBindings.swift | 26 ++ .../SpaceCreationSettingsViewState.swift | 34 ++ .../SpaceCreationSettingsService.swift | 136 ++++++++ ...MockSpaceCreationSettingsScreenState.swift | 67 ++++ .../MockSpaceCreationSettingsService.swift | 46 +++ ...SpaceCreationSettingsServiceProtocol.swift | 30 ++ .../UI/SpaceCreationSettingsUITests.swift | 51 +++ .../SpaceCreationSettingsViewModelTests.swift | 66 ++++ .../View/SpaceCreationSettings.swift | 164 +++++++++ .../SpaceCreationSettingsViewModel.swift | 179 ++++++++++ ...aceCreationSettingsViewModelProtocol.swift | 25 ++ 145 files changed, 7280 insertions(+), 11 deletions(-) create mode 100644 Riot/Assets/Images.xcassets/Spaces/spaces_add_space.imageset/Contents.json create mode 100644 Riot/Assets/Images.xcassets/Spaces/spaces_add_space.imageset/spaces_add_space.png create mode 100644 Riot/Assets/Images.xcassets/Spaces/spaces_add_space.imageset/spaces_add_space@2x.png create mode 100644 Riot/Assets/Images.xcassets/Spaces/spaces_add_space.imageset/spaces_add_space@3x.png create mode 100644 Riot/Assets/Images.xcassets/Spaces/spaces_invite_users.imageset/Contents.json create mode 100644 Riot/Assets/Images.xcassets/Spaces/spaces_invite_users.imageset/spaces_invite_users.png create mode 100644 Riot/Assets/Images.xcassets/Spaces/spaces_invite_users.imageset/spaces_invite_users@2x.png create mode 100644 Riot/Assets/Images.xcassets/Spaces/spaces_invite_users.imageset/spaces_invite_users@3x.png create mode 100644 Riot/Assets/Images.xcassets/Spaces/spaces_modal_close.imageset/Contents.json create mode 100644 Riot/Assets/Images.xcassets/Spaces/spaces_modal_close.imageset/spaces_modal_close.png create mode 100644 Riot/Assets/Images.xcassets/Spaces/spaces_modal_close.imageset/spaces_modal_close@2x.png create mode 100644 Riot/Assets/Images.xcassets/Spaces/spaces_modal_close.imageset/spaces_modal_close@3x.png create mode 100644 RiotSwiftUI/Modules/Common/Avatar/View/SpaceAvatarImage.swift create mode 100644 RiotSwiftUI/Modules/Common/Extensions/MatrixSDK/MXUserAvatarable.swift create mode 100644 RiotSwiftUI/Modules/Common/Util/ClearViewModifier.swift create mode 100644 RiotSwiftUI/Modules/Common/Util/NavigationBarConfigurator.swift create mode 100644 RiotSwiftUI/Modules/Common/Util/NextViewModifier.swift create mode 100644 RiotSwiftUI/Modules/Common/Util/OptionButton.swift create mode 100644 RiotSwiftUI/Modules/Common/Util/ResponderManager.swift create mode 100644 RiotSwiftUI/Modules/Common/Util/RoundedBorderTextEditor.swift create mode 100644 RiotSwiftUI/Modules/Common/Util/RoundedBorderTextField.swift create mode 100644 RiotSwiftUI/Modules/Common/Util/SearchBar.swift create mode 100644 RiotSwiftUI/Modules/Common/Util/ThemableButton.swift create mode 100644 RiotSwiftUI/Modules/Common/Util/ThemableTextEditor.swift create mode 100644 RiotSwiftUI/Modules/Common/Util/ThemableTextField.swift create mode 100644 RiotSwiftUI/Modules/Common/Util/View+Riot.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/Coordinator/SpaceCreationCoordinator.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/Coordinator/SpaceCreationCoordinatorAction.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/Coordinator/SpaceCreationCoordinatorParameters.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Coordinator/SpaceCreationEmailInvitesCoordinator.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Coordinator/SpaceCreationEmailInvitesCoordinatorParameters.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Model/SpaceCreationEmailInvitesCoordinatorAction.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Model/SpaceCreationEmailInvitesPresence.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Model/SpaceCreationEmailInvitesStateAction.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Model/SpaceCreationEmailInvitesViewAction.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Model/SpaceCreationEmailInvitesViewModelBindings.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Model/SpaceCreationEmailInvitesViewModelResult.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Model/SpaceCreationEmailInvitesViewState.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Service/MatrixSDK/SpaceCreationEmailInvitesService.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Service/Mock/MockSpaceCreationEmailInvitesScreenState.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Service/Mock/MockSpaceCreationEmailInvitesService.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Service/SpaceCreationEmailInvitesServiceProtocol.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Test/UI/SpaceCreationEmailInvitesUITests.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Test/Unit/SpaceCreationEmailInvitesViewModelTests.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/View/SpaceCreationEmailInvites.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/ViewModel/SpaceCreationEmailInvitesViewModel.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/ViewModel/SpaceCreationEmailInvitesViewModelProtocol.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Coordinator/SpaceCreationMatrixItemChooserCoordinator.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Coordinator/SpaceCreationMatrixItemChooserCoordinatorParameters.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Model/SpaceCreationMatrixItem.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Model/SpaceCreationMatrixItemChooserCoordinatorAction.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Model/SpaceCreationMatrixItemListStateAction.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Model/SpaceCreationMatrixItemListStateActionListViewAction.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Model/SpaceCreationMatrixItemListStateActionListViewModelAction.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Model/SpaceCreationMatrixItemListStateActionListViewState.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Model/SpaceCreationMatrixItemType.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Service/MatrixSDK/SpaceCreationMatrixItemChooserService.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Service/Mock/MockSpaceCreationMatrixItemChooserScreenState.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Service/Mock/MockSpaceCreationMatrixItemChooserService.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Service/SpaceCreationMatrixItemChooserServiceProtocol.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Test/UI/SpaceCreationMatrixItemChooserUITests.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Test/Unit/SpaceCreationMatrixItemChooserViewModelTests.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/View/SpaceCreationMatrixItemChooser.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/View/SpaceCreationMatrixItemChooserListRow.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/ViewModel/SpaceCreationMatrixItemChooserViewModel.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/ViewModel/SpaceCreationMatrixItemChooserViewModelProtocol.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Coordinator/SpaceCreationMenuCoordinator.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Coordinator/SpaceCreationMenuCoordinatorParamaters.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Model/SpaceCreationMenuCoordinatorAction.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Model/SpaceCreationMenuRoom.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Model/SpaceCreationMenuStateAction.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Model/SpaceCreationMenuViewAction.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Model/SpaceCreationMenuViewModelAction.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Model/SpaceCreationMenuViewState.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Model/SpaceCreationParameters.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Test/UI/SpaceCreationMenuUITests.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Test/Unit/SpaceCreationMenuViewModelTests.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/View/SpaceCreationMenu.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/ViewModel/SpaceCreationMenuViewModel.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/ViewModel/SpaceCreationMenuViewModelProtocol.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Coordinator/SpaceCreationPostProcessCoordinator.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Coordinator/SpaceCreationPostProcessCoordinatorParameters.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Model/SpaceCreationPostProcessCoordinatorAction.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Model/SpaceCreationPostProcessPresence.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Model/SpaceCreationPostProcessStateAction.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Model/SpaceCreationPostProcessTask.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Model/SpaceCreationPostProcessViewAction.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Model/SpaceCreationPostProcessViewModelResult.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Model/SpaceCreationPostProcessViewState.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Service/MatrixSDK/SpaceCreationPostProcessService.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Service/Mock/MockSpaceCreationPostProcessScreenState.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Service/Mock/MockSpaceCreationPostProcessService.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Service/SpaceCreationPostProcessServiceProtocol.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Test/UI/SpaceCreationPostProcessUITests.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Test/Unit/SpaceCreationPostProcessViewModelTests.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/View/SpaceCreationPostProcess.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/View/SpaceCreationPostProcessItem.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/ViewModel/SpaceCreationPostProcessViewModel.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/ViewModel/SpaceCreationPostProcessViewModelProtocol.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Coordinator/SpaceCreationRoomsCoordinator.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Coordinator/SpaceCreationRoomsCoordinatorParameters.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Model/SpaceCreationRoomsCoordinatorAction.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Model/SpaceCreationRoomsPresence.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Model/SpaceCreationRoomsStateAction.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Model/SpaceCreationRoomsViewAction.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Model/SpaceCreationRoomsViewModelBindings.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Model/SpaceCreationRoomsViewModelResult.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Model/SpaceCreationRoomsViewState.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Service/Mock/MockSpaceCreationRoomsScreenState.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Test/UI/SpaceCreationRoomsUITests.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Test/Unit/SpaceCreationRoomsViewModelTests.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/View/SpaceCreationRooms.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/ViewModel/SpaceCreationRoomsViewModel.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/ViewModel/SpaceCreationRoomsViewModelProtocol.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Coordinator/SpaceCreationSettingsCoordinator.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Coordinator/SpaceCreationSettingsCoordinatorParamaters.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Model/SpaceCreationSettingsAddressValidationStatus.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Model/SpaceCreationSettingsCoordinatorAction.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Model/SpaceCreationSettingsStateAction.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Model/SpaceCreationSettingsViewAction.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Model/SpaceCreationSettingsViewModelAction.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Model/SpaceCreationSettingsViewModelBindings.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Model/SpaceCreationSettingsViewState.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Service/MatrixSDK/SpaceCreationSettingsService.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Service/Mock/MockSpaceCreationSettingsScreenState.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Service/Mock/MockSpaceCreationSettingsService.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Service/SpaceCreationSettingsServiceProtocol.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Test/UI/SpaceCreationSettingsUITests.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Test/Unit/SpaceCreationSettingsViewModelTests.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/View/SpaceCreationSettings.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/ViewModel/SpaceCreationSettingsViewModel.swift create mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/ViewModel/SpaceCreationSettingsViewModelProtocol.swift diff --git a/Riot/Assets/Images.xcassets/Spaces/spaces_add_space.imageset/Contents.json b/Riot/Assets/Images.xcassets/Spaces/spaces_add_space.imageset/Contents.json new file mode 100644 index 000000000..ae5d36081 --- /dev/null +++ b/Riot/Assets/Images.xcassets/Spaces/spaces_add_space.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "spaces_add_space.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "spaces_add_space@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "spaces_add_space@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Riot/Assets/Images.xcassets/Spaces/spaces_add_space.imageset/spaces_add_space.png b/Riot/Assets/Images.xcassets/Spaces/spaces_add_space.imageset/spaces_add_space.png new file mode 100644 index 0000000000000000000000000000000000000000..38b53525e6a53a51281ae38af1bc433f26292d56 GIT binary patch literal 166 zcmeAS@N?(olHy`uVBq!ia0vp^JRr=$1|-8uW1a&k&H|6fVg?3oVGw3ym^DWND9BhG z|!S3j3^ HP6hL xGQ5jLg6bCi!E<57&PyRooOL=C3=3N2ycvwUt^w_1@O1TaS?83{1OSTVL8AZw literal 0 HcmV?d00001 diff --git a/Riot/Assets/Images.xcassets/Spaces/spaces_add_space.imageset/spaces_add_space@3x.png b/Riot/Assets/Images.xcassets/Spaces/spaces_add_space.imageset/spaces_add_space@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..a8d57e5603c2fccada1ad97703d65460eb2a2098 GIT binary patch literal 246 zcmeAS@N?(olHy`uVBq!ia0vp^Dj>|k1|%Oc%$NbBI14-?iy0UcEkKyjb(&!UP>``W z$lZxy-8q?;Kn_c~qpu?a!^VE@KZ&eBez&KKV@L(#+iM$nn;m#sAEv86UeI9VXcVM& z(!=18=$YgPRsu&S`y3Q~uN@mv|L{;7_pZDjKMo)NtE028SW|eCOmCpTVg)zHo+b%a zL$0J*+*Q}anLOXU@ZbC)@ph+9_1E{Tycc;5-^!N$ZJZdwFVXftG2;j8B88I$jr0Ck dYjhy#*4E0AJnhHla~J3=22WQ%mvv4FO#l(^Q@H>D literal 0 HcmV?d00001 diff --git a/Riot/Assets/Images.xcassets/Spaces/spaces_invite_users.imageset/Contents.json b/Riot/Assets/Images.xcassets/Spaces/spaces_invite_users.imageset/Contents.json new file mode 100644 index 000000000..d6068239b --- /dev/null +++ b/Riot/Assets/Images.xcassets/Spaces/spaces_invite_users.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "spaces_invite_users.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "spaces_invite_users@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "spaces_invite_users@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Riot/Assets/Images.xcassets/Spaces/spaces_invite_users.imageset/spaces_invite_users.png b/Riot/Assets/Images.xcassets/Spaces/spaces_invite_users.imageset/spaces_invite_users.png new file mode 100644 index 0000000000000000000000000000000000000000..5c6b4401b99e513e9cebfc3400fbcbd37705f406 GIT binary patch literal 474 zcmV<00VV#4P)P000;W1^@s654Bdt00009a7bBm000XU z000XU0RWnu7ytkO0drDELIAGL9O(c600d`2O+f$vv5yPVR|r9Y6=T15$zyZ~|$OV*3I% z7Kv~09jx(6o~QNhTZV>-j&DNG-*{F^b>;Oiu`!N@5kiCj1>h_wVhjy&csl72V?9O) ziJ@)Q#H=Y-y5CqP1aosjK)iU*9-33wSSAH|Mvsc9E5}DqHYQv@j-og~s}(}4RNkH} zs?L1~k$6hM*OV49r%VRt(;kS$%YqurP2<_+U<5~MMY5LkU%ti(dVh8`oWp_I!gC?^ z%6Hf<9I5Xv(}q}+S`qDuxmmsmae{;+wIVX^r^wQC_XrwM9w01ilX}WR11M!IE&~N1agAGc)DG-Axf^4TEk%_2>-0jC-MJ)Z}pMM Q@Bjb+07*qoM6N<$f?>_VFaQ7m literal 0 HcmV?d00001 diff --git a/Riot/Assets/Images.xcassets/Spaces/spaces_invite_users.imageset/spaces_invite_users@2x.png b/Riot/Assets/Images.xcassets/Spaces/spaces_invite_users.imageset/spaces_invite_users@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..4a36ffbe273954bfbf1de21f5826751077c317f7 GIT binary patch literal 803 zcmV+;1Kj+HP)(wT7h?7%VwUF90EeI8MW2E15#jM-F-IKBcR!rv!F`u>?dAp2>V2`TTFO zkt;jsMAU_pWojA8Hz4@nE)&Um(AR#R)jzi1R)Br|?Tw8^pN;K&Y0 zFcxp4|C+zD2Au8P90Q&ME-h5p7w6}m>o(3{PGpK5F(K>P-z~{6cE-m@*ML89&RH9- z7Tnlv&J=>%dS2ZDV#owkYi|lV;=G-1g`I=o400|=2q=BJpFX+ z0TCpNSRZxkrol(AiHhU2jRVyzNCY6NTnKZEr!Qr$OUFD7(hC`Y`5><+O`JBB0gW>gbtyCbl|~J^(_<&O#!=*Qbln z(HLaeZ9M4%A>_yDji}w2UM4O()RT{iiTMv8+lBmM6i=sZg(}N{ z-I0^0Q>DHkU{6R%8pswBFsz{6&Qwqh3r)89m40aiJfXK&_r<0M3ow^}pA!csOcCl2 zcw#UpyRYYq5upV*wLP07JDgJbn+t{5m?virl~Pup?H3u~k#3S@Q~~-)+V}Ghtox?{V)i>+3-)cI3gRj%qrm|~i%?WX|LK!WaI(7zJi zzu7x(o(EIP(&~eGDUke52z6F2KOQxAYDGdq|Hw#f)ZD2R=_ELjGT=nY0Kyay1g$Dm zf2;mD#&~&Cd^8S5u*cb#*+UEC&v6*G;)xfjxw^3}K77axb|zXR8RikZ$DqiB`y{M_ z1}0jhgq1oZd<`fd*H64p3R?GSY*4OKa@eZSqd5__{2zv5;<$KpeR5`ZYbv7KaWq#R zb^vkiU<7;Z_2$3F&2@QF2AoJ4a3W=3YDE&|k?xU^-l(}Hu!#J*;Qdi)cnr=aK*Q)m z^f3X5!n@ZZC=i(*u)(07I&XrAm-<&n&9xHUQgQyOtYHH4@J*bbz#aj=^Jx8g2P|V; zq~O+rI%7Y@bk>-=e047+SoAc@tM zQrdqX*!rZnt3a(B%91e%W;cz(AFxz*NZKatRpHSAj@vKKsCrmF_ zRS&nCP!{Rp{r-GRMDIj$+2wnCk-QBHhACX6w59!LWdVtNP`U*>L^diDq$f##ur=zz zjBW}ON%qJXqHHMoP#h5_x>NM!3hg2dTiNN0A0N!I#ryrmqAW{mJ*jznV`13qH!6^% zA^tHRmTtETX@zSml`O5bH-*1wiLbwdCB(7t&z-+ieYNxAIcP-7aci!&#qpwfI5l9W zz23YEnozc+N+h-$&&UX*fyL}MC6;*5&4uHH$(NuEk&Yhf4N24f9P9- zQw`OI`&1*hNNH_)3_wudXD*)``W z$lZxy-8q?;Kn_c~qpu?a!^VE@KZ&eBIR;M`$B+ufwUZC>HW`St#YcZx(kXp{V^?F| z0%;4TV{%st6fUwXo>0PSUcl^jK(K&WNT1DhiA0A(w9gm&2_NqLumAEfsG$`e^$o;LCl%Dh0jT0+Eb<2O2(rRr866M{S8iz9uq zmIVMB-c904_`wm_U_2*!lF(%F)M*dW+09o5Y}X^Er}Z7^S}h9*aKT5px^XcG6mZh* zw0jf@6}YbeMM4!GC=f>?4m?yqMItWzd#~y7_J<#c-6!nkl8OtDED$E~OG*LmghRvx zy$%XoNg#s6Q1JJ(3fzeXiebRB0gsk6F~0|jQ91#?uYEGA9aBlbAI_XxZGj>YNg(7u zPun+WpK^Ch6`0m!sCNgaz}+>91gFBO5>0x$iEP)^N;cg#H-O$?($K1{Qj$ujl}aM!22D@Utl5Cru;mCGArdU( zF}A@$B%ZN7{yY+Bo`ism?RhU@{s3rbXn4|)q79!8CPTs@z5jK_u9IGt_2y6m>ENhM z2^oqX+rr_Jb?wLW~HzJelVk zixsuVz67d?RAXtC>&{*9iKWeTXHmWMc~P+)c}$HkMV!;ty?yR zao))eKc4{-2_TVv|0CXg(zcCj0XfFJ6-W65q&%hcRGe{~#4sOEg9Ci!mpev^2nwhw zzuY-OMCD_^E-%kE`5SQ+j{107=@W31e>hdfe6sTn@RirZitq}kDzAwV;T#YuufdLR z3c!-rU`AL2u;n#a5v2jK zVt-BB)&i1^R<}-hjV(gZHQjP}+zZ8%*VrPQ{)BR>{tv%0FuuH|Bw}hBzZQ^@-?+Okm@<0PgNBBN=f^E+dN6{Bo>*A`0000 String { + return VectorL10n.tr("Vector", "spaces_creation_address_already_exists", p1) + } + /// Your space will be viewable at\n%@ + public static func spacesCreationAddressDefaultMessage(_ p1: String) -> String { + return VectorL10n.tr("Vector", "spaces_creation_address_default_message", p1) + } + /// %@\nhas invalid characters + public static func spacesCreationAddressInvalidCharacters(_ p1: String) -> String { + return VectorL10n.tr("Vector", "spaces_creation_address_invalid_characters", p1) + } + /// Email + public static var spacesCreationEmailInvitesEmailTitle: String { + return VectorL10n.tr("Vector", "spaces_creation_email_invites_email_title") + } + /// You can invite them later too. + public static var spacesCreationEmailInvitesMessage: String { + return VectorL10n.tr("Vector", "spaces_creation_email_invites_message") + } + /// Invite your team + public static var spacesCreationEmailInvitesTitle: String { + return VectorL10n.tr("Vector", "spaces_creation_email_invites_title") + } + /// Name the room + public static var spacesCreationEmptyRoomNameError: String { + return VectorL10n.tr("Vector", "spaces_creation_empty_room_name_error") + } + /// You can change this later + public static var spacesCreationFooter: String { + return VectorL10n.tr("Vector", "spaces_creation_footer") + } + /// Spaces are a new way to group rooms and people. + public static var spacesCreationHint: String { + return VectorL10n.tr("Vector", "spaces_creation_hint") + } + /// Invite by username + public static var spacesCreationInviteByUsername: String { + return VectorL10n.tr("Vector", "spaces_creation_invite_by_username") + } + /// You can invite them later too. + public static var spacesCreationInviteByUsernameMessage: String { + return VectorL10n.tr("Vector", "spaces_creation_invite_by_username_message") + } + /// Invite your team + public static var spacesCreationInviteByUsernameTitle: String { + return VectorL10n.tr("Vector", "spaces_creation_invite_by_username_title") + } + /// General + public static var spacesCreationNewRoomsGeneral: String { + return VectorL10n.tr("Vector", "spaces_creation_new_rooms_general") + } + /// We’ll create a room for each one. + public static var spacesCreationNewRoomsMessage: String { + return VectorL10n.tr("Vector", "spaces_creation_new_rooms_message") + } + /// Random + public static var spacesCreationNewRoomsRandom: String { + return VectorL10n.tr("Vector", "spaces_creation_new_rooms_random") + } + /// Room name + public static var spacesCreationNewRoomsRoomNameTitle: String { + return VectorL10n.tr("Vector", "spaces_creation_new_rooms_room_name_title") + } + /// Support + public static var spacesCreationNewRoomsSupport: String { + return VectorL10n.tr("Vector", "spaces_creation_new_rooms_support") + } + /// What are some discussions you’ll have? + public static var spacesCreationNewRoomsTitle: String { + return VectorL10n.tr("Vector", "spaces_creation_new_rooms_title") + } + /// Adding %@ rooms + public static func spacesCreationPostProcessAddingRooms(_ p1: String) -> String { + return VectorL10n.tr("Vector", "spaces_creation_post_process_adding_rooms", p1) + } + /// Creating room %@ + public static func spacesCreationPostProcessCreatingRoom(_ p1: String) -> String { + return VectorL10n.tr("Vector", "spaces_creation_post_process_creating_room", p1) + } + /// Creating space + public static var spacesCreationPostProcessCreatingSpace: String { + return VectorL10n.tr("Vector", "spaces_creation_post_process_creating_space") + } + /// Creating space %@ + public static func spacesCreationPostProcessCreatingSpaceTask(_ p1: String) -> String { + return VectorL10n.tr("Vector", "spaces_creation_post_process_creating_space_task", p1) + } + /// Inviting %@ users + public static func spacesCreationPostProcessInvitingUsers(_ p1: String) -> String { + return VectorL10n.tr("Vector", "spaces_creation_post_process_inviting_users", p1) + } + /// Uploading avatar + public static var spacesCreationPostProcessUploadingAvatar: String { + return VectorL10n.tr("Vector", "spaces_creation_post_process_uploading_avatar") + } + /// Your private space + public static var spacesCreationPrivateSpaceTitle: String { + return VectorL10n.tr("Vector", "spaces_creation_private_space_title") + } + /// Your public space + public static var spacesCreationPublicSpaceTitle: String { + return VectorL10n.tr("Vector", "spaces_creation_public_space_title") + } + /// Add some details to help it stand out. You can change these at any point. + public static var spacesCreationSettingsMessage: String { + return VectorL10n.tr("Vector", "spaces_creation_settings_message") + } + /// A private space to organise your rooms + public static var spacesCreationSharingTypeJustMeDetail: String { + return VectorL10n.tr("Vector", "spaces_creation_sharing_type_just_me_detail") + } + /// Just me + public static var spacesCreationSharingTypeJustMeTitle: String { + return VectorL10n.tr("Vector", "spaces_creation_sharing_type_just_me_title") + } + /// A private space for you & your teammates + public static var spacesCreationSharingTypeMeAndTeammatesDetail: String { + return VectorL10n.tr("Vector", "spaces_creation_sharing_type_me_and_teammates_detail") + } + /// Me and teammates + public static var spacesCreationSharingTypeMeAndTeammatesTitle: String { + return VectorL10n.tr("Vector", "spaces_creation_sharing_type_me_and_teammates_title") + } + /// Make sure the right people have access to %@. You can change this later. + public static func spacesCreationSharingTypeMessage(_ p1: String) -> String { + return VectorL10n.tr("Vector", "spaces_creation_sharing_type_message", p1) + } + /// Who are you working with? + public static var spacesCreationSharingTypeTitle: String { + return VectorL10n.tr("Vector", "spaces_creation_sharing_type_title") + } + /// To join an existing space, you need an invite. + public static var spacesCreationVisibilityMessage: String { + return VectorL10n.tr("Vector", "spaces_creation_visibility_message") + } + /// What type of space do you want to create? + public static var spacesCreationVisibilityTitle: String { + return VectorL10n.tr("Vector", "spaces_creation_visibility_title") + } /// Some rooms may be hidden because they’re private and you need an invite. public static var spacesEmptySpaceDetail: String { return VectorL10n.tr("Vector", "spaces_empty_space_detail") diff --git a/Riot/Modules/Application/LegacyAppDelegate.m b/Riot/Modules/Application/LegacyAppDelegate.m index 75060f84b..986181173 100644 --- a/Riot/Modules/Application/LegacyAppDelegate.m +++ b/Riot/Modules/Application/LegacyAppDelegate.m @@ -190,6 +190,8 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni The launch animation container view */ UIView *launchAnimationContainerView; + + id graphUpdateObserver; } @property (strong, nonatomic) UIAlertController *mxInAppNotification; @@ -4520,10 +4522,10 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni else { MXWeakify(self); - __block __weak id observer = [[NSNotificationCenter defaultCenter] addObserverForName:MXSpaceService.didBuildSpaceGraph object:nil queue:nil usingBlock:^(NSNotification * _Nonnull note) { + graphUpdateObserver = [[NSNotificationCenter defaultCenter] addObserverForName:MXSpaceService.didBuildSpaceGraph object:nil queue:nil usingBlock:^(NSNotification * _Nonnull note) { MXStrongifyAndReturnIfNil(self); - [[NSNotificationCenter defaultCenter] removeObserver:observer]; + [[NSNotificationCenter defaultCenter] removeObserver:graphUpdateObserver]; if ([session.spaceService getSpaceWithId:spaceId]) { [self restoreInitialDisplay:^{ diff --git a/Riot/Modules/Common/SwiftUI/VectorHostingController.swift b/Riot/Modules/Common/SwiftUI/VectorHostingController.swift index 3e9ad98b4..4ba63ee6f 100644 --- a/Riot/Modules/Common/SwiftUI/VectorHostingController.swift +++ b/Riot/Modules/Common/SwiftUI/VectorHostingController.swift @@ -26,6 +26,7 @@ class VectorHostingController: UIHostingController { // MARK: Private + var hidesBackTitleWhenPushed: Bool = false private var theme: Theme init(rootView: Content) where Content: View { @@ -48,6 +49,14 @@ class VectorHostingController: UIHostingController { self.update(theme: self.theme) } + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + if hidesBackTitleWhenPushed { + vc_removeBackTitle() + } + } + override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() diff --git a/Riot/Modules/SideMenu/SideMenuCoordinator.swift b/Riot/Modules/SideMenu/SideMenuCoordinator.swift index 797e20818..59eb8ca91 100644 --- a/Riot/Modules/SideMenu/SideMenuCoordinator.swift +++ b/Riot/Modules/SideMenu/SideMenuCoordinator.swift @@ -64,6 +64,7 @@ final class SideMenuCoordinator: NSObject, SideMenuCoordinatorType { private var exploreRoomCoordinator: ExploreRoomCoordinator? private var membersCoordinator: SpaceMembersCoordinator? + private var createSpaceCoordinator: SpaceCreationCoordinator? // MARK: Public @@ -257,6 +258,36 @@ final class SideMenuCoordinator: NSObject, SideMenuCoordinatorType { self.spaceDetailPresenter.present(forSpaceWithId: spaceId, from: self.sideMenuViewController, sourceView: sourceView, session: session, animated: true) } + @available(iOS 14.0, *) + private func showCreateSpace() { + guard let session = self.parameters.userSessionsService.mainUserSession?.matrixSession else { + return + } + + let coordinator = SpaceCreationCoordinator(parameters: SpaceCreationCoordinatorParameters(session: session)) + let presentable = coordinator.toPresentable() + presentable.presentationController?.delegate = self + self.sideMenuViewController.present(presentable, animated: true, completion: nil) + coordinator.callback = { [weak self] result in + guard let self = self else { + return + } + + self.createSpaceCoordinator?.toPresentable().dismiss(animated: true) { + self.createSpaceCoordinator = nil + switch result { + case .cancel: + break + case .done(let spaceId): + self.select(spaceWithId: spaceId) + } + } + } + coordinator.start() + + self.createSpaceCoordinator = coordinator + } + // MARK: UserSessions management private func registerUserSessionsServiceNotifications() { @@ -310,7 +341,7 @@ extension SideMenuCoordinator: SideMenuNavigationControllerDelegate { // MARK: - SideMenuNavigationControllerDelegate extension SideMenuCoordinator: SpaceListCoordinatorDelegate { - func spaceListCoordinatorDidSelectHomeSpace(_ coordinator: SpaceListCoordinatorType) { + func spaceListCoordinatorDidSelectHomeSpace(_ coordinator: SpaceListCoordinatorType) { self.parameters.appNavigator.sideMenu.dismiss(animated: true) { } @@ -331,6 +362,12 @@ extension SideMenuCoordinator: SpaceListCoordinatorDelegate { func spaceListCoordinator(_ coordinator: SpaceListCoordinatorType, didPressMoreForSpaceWithId spaceId: String, from sourceView: UIView) { self.showMenu(forSpaceWithId: spaceId, from: sourceView) } + + func spaceListCoordinatorDidSelectCreateSpace(_ coordinator: SpaceListCoordinatorType) { + if #available(iOS 14.0, *) { + self.showCreateSpace() + } + } } // MARK: - SpaceMenuPresenterDelegate @@ -386,5 +423,6 @@ extension SideMenuCoordinator: UIAdaptivePresentationControllerDelegate { func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { self.exploreRoomCoordinator = nil self.membersCoordinator = nil + self.createSpaceCoordinator = nil } } diff --git a/Riot/Modules/Spaces/SpaceList/SpaceListCoordinator.swift b/Riot/Modules/Spaces/SpaceList/SpaceListCoordinator.swift index 5d6924347..e8cfc5db5 100644 --- a/Riot/Modules/Spaces/SpaceList/SpaceListCoordinator.swift +++ b/Riot/Modules/Spaces/SpaceList/SpaceListCoordinator.swift @@ -85,4 +85,9 @@ extension SpaceListCoordinator: SpaceListViewModelCoordinatorDelegate { func spaceListViewModel(_ viewModel: SpaceListViewModelType, didPressMoreForSpaceWithId spaceId: String, from sourceView: UIView) { self.delegate?.spaceListCoordinator(self, didPressMoreForSpaceWithId: spaceId, from: sourceView) } + + func spaceListViewModelDidSelectCreateSpace(_ viewModel: SpaceListViewModelType) { + self.delegate?.spaceListCoordinatorDidSelectCreateSpace(self) + } + } diff --git a/Riot/Modules/Spaces/SpaceList/SpaceListCoordinatorType.swift b/Riot/Modules/Spaces/SpaceList/SpaceListCoordinatorType.swift index 8ab4815bc..03383216e 100644 --- a/Riot/Modules/Spaces/SpaceList/SpaceListCoordinatorType.swift +++ b/Riot/Modules/Spaces/SpaceList/SpaceListCoordinatorType.swift @@ -23,6 +23,7 @@ protocol SpaceListCoordinatorDelegate: AnyObject { func spaceListCoordinator(_ coordinator: SpaceListCoordinatorType, didSelectSpaceWithId spaceId: String) func spaceListCoordinator(_ coordinator: SpaceListCoordinatorType, didSelectInviteWithId spaceId: String, from sourceView: UIView?) func spaceListCoordinator(_ coordinator: SpaceListCoordinatorType, didPressMoreForSpaceWithId spaceId: String, from sourceView: UIView) + func spaceListCoordinatorDidSelectCreateSpace(_ coordinator: SpaceListCoordinatorType) } /// `SpaceListCoordinatorType` is a protocol describing a Coordinator that handle key backup setup passphrase navigation flow. diff --git a/Riot/Modules/Spaces/SpaceList/SpaceListSection.swift b/Riot/Modules/Spaces/SpaceList/SpaceListSection.swift index f4cc0c399..8877dbac2 100644 --- a/Riot/Modules/Spaces/SpaceList/SpaceListSection.swift +++ b/Riot/Modules/Spaces/SpaceList/SpaceListSection.swift @@ -20,4 +20,5 @@ import Foundation enum SpaceListSection { case home(_ viewData: SpaceListItemViewData) case spaces(_ viewDataList: [SpaceListItemViewData]) + case addSpace(_ viewData: SpaceListItemViewData) } diff --git a/Riot/Modules/Spaces/SpaceList/SpaceListViewCell.swift b/Riot/Modules/Spaces/SpaceList/SpaceListViewCell.swift index eaf3a5e09..b952b336c 100644 --- a/Riot/Modules/Spaces/SpaceList/SpaceListViewCell.swift +++ b/Riot/Modules/Spaces/SpaceList/SpaceListViewCell.swift @@ -58,7 +58,7 @@ final class SpaceListViewCell: UITableViewCell, Themable, NibReusable { func fill(with viewData: SpaceListItemViewData) { self.avatarView.fill(with: viewData.avatarViewData) self.titleLabel.text = viewData.title - self.moreButton.isHidden = viewData.isInvite + self.moreButton.isHidden = viewData.spaceId == SpaceListViewModel.Constants.addSpaceId || viewData.isInvite if viewData.isInvite { self.isBadgeAlert = true self.badgeLabel.isHidden = false @@ -68,7 +68,7 @@ final class SpaceListViewCell: UITableViewCell, Themable, NibReusable { self.badgeLabel.text = "!" } else { self.isBadgeAlert = viewData.highlightedNotificationCount > 0 - let notificationCount = viewData.notificationCount + viewData.highlightedNotificationCount + let notificationCount = viewData.notificationCount self.badgeLabel.isHidden = notificationCount == 0 if let theme = self.theme { self.badgeLabel.badgeColor = viewData.highlightedNotificationCount == 0 ? theme.colors.tertiaryContent : theme.colors.alert diff --git a/Riot/Modules/Spaces/SpaceList/SpaceListViewController.swift b/Riot/Modules/Spaces/SpaceList/SpaceListViewController.swift index 79f5f2dba..f0f254e18 100644 --- a/Riot/Modules/Spaces/SpaceList/SpaceListViewController.swift +++ b/Riot/Modules/Spaces/SpaceList/SpaceListViewController.swift @@ -172,8 +172,10 @@ extension SpaceListViewController: UITableViewDataSource { numberOfRows = 1 case .spaces(let viewDataList): numberOfRows = viewDataList.count + case .addSpace: + numberOfRows = 1 } - + return numberOfRows } @@ -189,6 +191,8 @@ extension SpaceListViewController: UITableViewDataSource { viewData = spaceViewData case .spaces(let viewDataList): viewData = viewDataList[indexPath.row] + case .addSpace(let spaceViewData): + viewData = spaceViewData } cell.update(theme: self.theme) diff --git a/Riot/Modules/Spaces/SpaceList/SpaceListViewModel.swift b/Riot/Modules/Spaces/SpaceList/SpaceListViewModel.swift index 62770e05e..9432ff20f 100644 --- a/Riot/Modules/Spaces/SpaceList/SpaceListViewModel.swift +++ b/Riot/Modules/Spaces/SpaceList/SpaceListViewModel.swift @@ -24,6 +24,7 @@ final class SpaceListViewModel: SpaceListViewModelType { enum Constants { static let homeSpaceId: String = "home" + static let addSpaceId: String = "add_space" } // MARK: - Properties @@ -87,12 +88,16 @@ final class SpaceListViewModel: SpaceListViewModelType { self.selectedIndexPath = indexPath self.update(viewState: .selectionChanged(indexPath)) } + case .addSpace: + self.update(viewState: .selectionChanged(self.selectedIndexPath)) + addSpace() } case .moreAction(at: let indexPath, from: let sourceView): let section = self.sections[indexPath.section] switch section { case .home: self.coordinatorDelegate?.spaceListViewModel(self, didPressMoreForSpaceWithId: Constants.homeSpaceId, from: sourceView) + case .addSpace: break case .spaces(let viewDataList): let spaceViewData = viewDataList[indexPath.row] self.coordinatorDelegate?.spaceListViewModel(self, didPressMoreForSpaceWithId: spaceViewData.spaceId, from: sourceView) @@ -108,6 +113,7 @@ final class SpaceListViewModel: SpaceListViewModelType { for (sectionIndex, section) in self.sections.enumerated() { switch section { case .home: break + case .addSpace: break case .spaces(let viewDataList): for (row, itemViewData) in viewDataList.enumerated() where itemViewData.spaceId == spaceId { let indexPath = IndexPath(row: row, section: sectionIndex) @@ -142,7 +148,7 @@ final class SpaceListViewModel: SpaceListViewModelType { let homeViewData = self.createHomeViewData(session: session) let viewDataList = getSpacesViewData(session: session) - let sections: [SpaceListSection] = viewDataList.invites.isEmpty ? [ + var sections: [SpaceListSection] = viewDataList.invites.isEmpty ? [ .home(homeViewData), .spaces(viewDataList.spaces) ] @@ -152,6 +158,11 @@ final class SpaceListViewModel: SpaceListViewModelType { .home(homeViewData), .spaces(viewDataList.spaces) ] + + if #available(iOS 14.0, *) { + let addSpaceViewData = self.createAddSpaceViewData(session: session) + sections.append(.addSpace(addSpaceViewData)) + } self.sections = sections let homeIndexPath = viewDataList.invites.isEmpty ? IndexPath(row: 0, section: 0) : IndexPath(row: 0, section: 1) @@ -161,8 +172,8 @@ final class SpaceListViewModel: SpaceListViewModelType { var newSelection: IndexPath? let section = sections.last switch section { - case .home: - break + case .home: break + case .addSpace: break case .spaces(let viewDataList): var index = 0 for itemViewData in viewDataList { @@ -191,6 +202,10 @@ final class SpaceListViewModel: SpaceListViewModelType { self.coordinatorDelegate?.spaceListViewModelDidSelectHomeSpace(self) } + private func addSpace() { + self.coordinatorDelegate?.spaceListViewModelDidSelectCreateSpace(self) + } + private func selectSpace(with spaceId: String) { self.coordinatorDelegate?.spaceListViewModel(self, didSelectSpaceWithId: spaceId) } @@ -204,17 +219,38 @@ final class SpaceListViewModel: SpaceListViewModelType { let homeNotificationState = session.spaceService.notificationCounter.homeNotificationState let homeViewData = SpaceListItemViewData(spaceId: Constants.homeSpaceId, - title: VectorL10n.spacesHomeSpaceTitle, avatarViewData: avatarViewData, isInvite: false, notificationCount: homeNotificationState.allCount, highlightedNotificationCount: homeNotificationState.allHighlightCount) + title: VectorL10n.spacesHomeSpaceTitle, + avatarViewData: avatarViewData, + isInvite: false, + notificationCount: homeNotificationState.allCount, + highlightedNotificationCount: homeNotificationState.allHighlightCount) return homeViewData } + private func createAddSpaceViewData(session: MXSession) -> SpaceListItemViewData { + let avatarViewData = AvatarViewData(matrixItemId: Constants.addSpaceId, displayName: nil, avatarUrl: nil, mediaManager: session.mediaManager, fallbackImage: .image(Asset.Images.spacesAddSpace.image, .center)) + + let homeViewData = SpaceListItemViewData(spaceId: Constants.addSpaceId, + title: VectorL10n.spacesAddSpaceTitle, + avatarViewData: avatarViewData, + isInvite: false, + notificationCount: 0, + highlightedNotificationCount: 0) + return homeViewData + } + private func getSpacesViewData(session: MXSession) -> (invites: [SpaceListItemViewData], spaces: [SpaceListItemViewData]) { var invites: [SpaceListItemViewData] = [] var spaces: [SpaceListItemViewData] = [] session.spaceService.rootSpaceSummaries.forEach { summary in let avatarViewData = AvatarViewData(matrixItemId: summary.roomId, displayName: summary.displayname, avatarUrl: summary.avatar, mediaManager: session.mediaManager, fallbackImage: .matrixItem(summary.roomId, summary.displayname)) let notificationState = session.spaceService.notificationCounter.notificationState(forSpaceWithId: summary.roomId) - let viewData = SpaceListItemViewData(spaceId: summary.roomId, title: summary.displayname, avatarViewData: avatarViewData, isInvite: summary.membership == .invite, notificationCount: notificationState?.groupMissedDiscussionsCount ?? 0, highlightedNotificationCount: notificationState?.groupMissedDiscussionsHighlightedCount ?? 0) + let viewData = SpaceListItemViewData(spaceId: summary.roomId, + title: summary.displayname, + avatarViewData: avatarViewData, + isInvite: summary.membership == .invite, + notificationCount: notificationState?.groupMissedDiscussionsCount ?? 0, + highlightedNotificationCount: notificationState?.groupMissedDiscussionsHighlightedCount ?? 0) if viewData.isInvite { invites.append(viewData) } else { @@ -244,6 +280,8 @@ final class SpaceListViewModel: SpaceListViewModelType { case .spaces(let viewDataList): let spaceViewData = viewDataList[self.selectedIndexPath.row] return spaceViewData.spaceId + case .addSpace: + return Constants.addSpaceId } } diff --git a/Riot/Modules/Spaces/SpaceList/SpaceListViewModelType.swift b/Riot/Modules/Spaces/SpaceList/SpaceListViewModelType.swift index 00066a70d..cb706a8c2 100644 --- a/Riot/Modules/Spaces/SpaceList/SpaceListViewModelType.swift +++ b/Riot/Modules/Spaces/SpaceList/SpaceListViewModelType.swift @@ -27,6 +27,7 @@ protocol SpaceListViewModelCoordinatorDelegate: AnyObject { func spaceListViewModel(_ viewModel: SpaceListViewModelType, didSelectSpaceWithId spaceId: String) func spaceListViewModel(_ viewModel: SpaceListViewModelType, didSelectInviteWithId spaceId: String, from sourceView: UIView?) func spaceListViewModel(_ viewModel: SpaceListViewModelType, didPressMoreForSpaceWithId spaceId: String, from sourceView: UIView) + func spaceListViewModelDidSelectCreateSpace(_ viewModel: SpaceListViewModelType) } /// Protocol describing the view model used by `SpaceListViewController` diff --git a/RiotSwiftUI/Modules/Common/Avatar/View/SpaceAvatarImage.swift b/RiotSwiftUI/Modules/Common/Avatar/View/SpaceAvatarImage.swift new file mode 100644 index 000000000..c9d4ad85d --- /dev/null +++ b/RiotSwiftUI/Modules/Common/Avatar/View/SpaceAvatarImage.swift @@ -0,0 +1,109 @@ +// +// 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 DesignKit + +@available(iOS 14.0, *) +struct SpaceAvatarImage: View { + + @Environment(\.theme) var theme: ThemeSwiftUI + @Environment(\.dependencies) var dependencies: DependencyContainer + @StateObject var viewModel = AvatarViewModel() + + var mxContentUri: String? + var matrixItemId: String + var displayName: String? + var size: AvatarSize + + var body: some View { + Group { + switch viewModel.viewState { + case .empty: + ProgressView() + case .placeholder(let firstCharacter, let colorIndex): + Text(firstCharacter) + .padding(10) + .frame(width: CGFloat(size.rawValue), height: CGFloat(size.rawValue)) + .foregroundColor(theme.colors.background) + .background(theme.colors.namesAndAvatars[colorIndex]) + .clipShape(RoundedRectangle(cornerRadius: 8)) + // Make the text resizable (i.e. Make it large and then allow it to scale down) + .font(.system(size: 200)) + .minimumScaleFactor(0.001) + case .avatar(let image): + Image(uiImage: image) + .resizable() + .frame(width: CGFloat(size.rawValue), height: CGFloat(size.rawValue)) + .clipShape(Circle()) + } + } + .onChange(of: displayName, perform: { value in + viewModel.loadAvatar( + mxContentUri: mxContentUri, + matrixItemId: matrixItemId, + displayName: value, + colorCount: theme.colors.namesAndAvatars.count, + avatarSize: size + ) + }) + .onAppear { + viewModel.inject(dependencies: dependencies) + viewModel.loadAvatar( + mxContentUri: mxContentUri, + matrixItemId: matrixItemId, + displayName: displayName, + colorCount: theme.colors.namesAndAvatars.count, + avatarSize: size + ) + } + } +} + +@available(iOS 14.0, *) +extension SpaceAvatarImage { + init(avatarData: AvatarInputProtocol, size: AvatarSize) { + self.init( + mxContentUri: avatarData.mxContentUri, + matrixItemId: avatarData.matrixItemId, + displayName: avatarData.displayName, + size: size + ) + } +} + +@available(iOS 14.0, *) +struct LiveAvatarImage_Previews: PreviewProvider { + static let mxContentUri = "fakeUri" + static let name = "Alice" + static var previews: some View { + Group { + HStack { + VStack(alignment: .center, spacing: 20) { + SpaceAvatarImage(avatarData: MockAvatarInput.example, size: .xSmall) + SpaceAvatarImage(avatarData: MockAvatarInput.example, size: .medium) + SpaceAvatarImage(avatarData: MockAvatarInput.example, size: .xLarge) + } + VStack(alignment: .center, spacing: 20) { + SpaceAvatarImage(mxContentUri: nil, matrixItemId: name, displayName: name, size: .xSmall) + SpaceAvatarImage(mxContentUri: nil, matrixItemId: name, displayName: name, size: .medium) + SpaceAvatarImage(mxContentUri: nil, matrixItemId: name, displayName: name, size: .xLarge) + } + } + .addDependency(MockAvatarService.example) + } + } +} diff --git a/RiotSwiftUI/Modules/Common/Extensions/MatrixSDK/MXUserAvatarable.swift b/RiotSwiftUI/Modules/Common/Extensions/MatrixSDK/MXUserAvatarable.swift new file mode 100644 index 000000000..701c2f75f --- /dev/null +++ b/RiotSwiftUI/Modules/Common/Extensions/MatrixSDK/MXUserAvatarable.swift @@ -0,0 +1,31 @@ +// +// 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 +extension MXUser: Avatarable { + var mxContentUri: String? { + avatarUrl + } + + var matrixItemId: String { + userId + } + + var displayName: String? { + displayname + } + +} diff --git a/RiotSwiftUI/Modules/Common/Util/ClearViewModifier.swift b/RiotSwiftUI/Modules/Common/Util/ClearViewModifier.swift new file mode 100644 index 000000000..edf478856 --- /dev/null +++ b/RiotSwiftUI/Modules/Common/Util/ClearViewModifier.swift @@ -0,0 +1,52 @@ +// +// 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 ClearViewModifier: ViewModifier +{ + // MARK: - Properties + + let alignment: VerticalAlignment + + // MARK: - Bindings + + @Binding var text: String + + // MARK: - Private + + @Environment(\.theme) private var theme: ThemeSwiftUI + + // MARK: - Public + + public func body(content: Content) -> some View + { + HStack(alignment: alignment) { + content + if !text.isEmpty { + Button(action: { + self.text = "" + }) { + Image(systemName: "xmark.circle.fill") + .renderingMode(.template) + .foregroundColor(theme.colors.quarterlyContent) + } + .padding(EdgeInsets(top: alignment == .top ? 8 : 0, leading: 0, bottom: alignment == .bottom ? 8 : 0, trailing: 8)) + } + } + } +} diff --git a/RiotSwiftUI/Modules/Common/Util/NavigationBarConfigurator.swift b/RiotSwiftUI/Modules/Common/Util/NavigationBarConfigurator.swift new file mode 100644 index 000000000..0e4bfe4d8 --- /dev/null +++ b/RiotSwiftUI/Modules/Common/Util/NavigationBarConfigurator.swift @@ -0,0 +1,71 @@ +// +// 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 13.0, *) +extension View { + func configureNavigationBar(configure: @escaping (UINavigationController) -> Void) -> some View { + modifier(NavigationConfigurationViewModifier(configure: configure)) + } +} + +@available(iOS 13.0.0, *) +struct NavigationConfigurationViewModifier: ViewModifier { + let configure: (UINavigationController) -> Void + + func body(content: Content) -> some View { + content.background(NavigationConfigurator(configure: configure)) + } +} + +@available(iOS 13.0.0, *) +struct NavigationConfigurator: UIViewControllerRepresentable { + let configure: (UINavigationController) -> Void + + func makeUIViewController( + context: UIViewControllerRepresentableContext + ) -> NavigationConfigurationViewController { + NavigationConfigurationViewController(configure: configure) + } + + func updateUIViewController( + _ uiViewController: NavigationConfigurationViewController, + context: UIViewControllerRepresentableContext + ) { } +} + +@available(iOS 13.0.0, *) +final class NavigationConfigurationViewController: UIViewController { + let configure: (UINavigationController) -> Void + + init(configure: @escaping (UINavigationController) -> Void) { + self.configure = configure + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + if let navigationController = navigationController { + configure(navigationController) + } + } +} diff --git a/RiotSwiftUI/Modules/Common/Util/NextViewModifier.swift b/RiotSwiftUI/Modules/Common/Util/NextViewModifier.swift new file mode 100644 index 000000000..a8a794480 --- /dev/null +++ b/RiotSwiftUI/Modules/Common/Util/NextViewModifier.swift @@ -0,0 +1,55 @@ +// +// 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 NextViewModifier: ViewModifier +{ + // MARK: - Properties + + let alignment: Alignment + + // MARK: - Bindings + + @Binding var isEditing: Bool + + // MARK: - Private + + @Environment(\.theme) private var theme: ThemeSwiftUI + + // MARK: - Public + + public func body(content: Content) -> some View + { + ZStack(alignment: alignment) { + content + if isEditing { + Button(action: { + if !ResponderManager.makeActiveNextResponder() { + ResponderManager.resignFirstResponder() + } + }) { + Image(systemName: "arrow.down.circle.fill") + .renderingMode(.template) + .foregroundColor(theme.colors.quarterlyContent) + } + .padding(EdgeInsets(top: alignment.vertical == .top ? 8 : 0, leading: 0, bottom: alignment.vertical == .bottom ? 8 : 0, trailing: 8)) + } + } + } +} + diff --git a/RiotSwiftUI/Modules/Common/Util/OptionButton.swift b/RiotSwiftUI/Modules/Common/Util/OptionButton.swift new file mode 100644 index 000000000..669fd3c15 --- /dev/null +++ b/RiotSwiftUI/Modules/Common/Util/OptionButton.swift @@ -0,0 +1,90 @@ +// +// 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 OptionButton: View { + + // MARK: - Style + + private struct Style: ButtonStyle { + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .scaleEffect(configuration.isPressed ? 0.97 : 1) + .animation(.easeOut(duration: 0.2), value: configuration.isPressed) + } + } + + // MARK: - Properties + + let icon: UIImage? + let title: String + let detailMessage: String? + let action: () -> Void + + // MARK: Private + + @Environment(\.theme) private var theme: ThemeSwiftUI + + // MARK: Public + + var body: some View { + Button(action: action, label: { + HStack { + if let image = icon { + Image(uiImage: image).renderingMode(.template).resizable().frame(width: 24, height: 24).foregroundColor(theme.colors.quarterlyContent) + } + VStack(alignment: .leading, spacing: nil) { + Text(title).font(theme.fonts.bodySB).foregroundColor(theme.colors.primaryContent) + if let detail = detailMessage { + Text(detail).font(theme.fonts.caption1).foregroundColor(theme.colors.secondaryContent) + } + } + Spacer() + Image(systemName: "chevron.right").font(.system(size: 16, weight: .regular)).foregroundColor(theme.colors.quarterlyContent) + } + .padding(EdgeInsets(top: 15, leading: 16, bottom: 15, trailing: 16)) + .background(theme.colors.system) + .foregroundColor(theme.colors.secondaryContent) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + ) + .buttonStyle(Style()) + } +} + +// MARK: - Previews + +@available(iOS 14.0, *) +struct OptionButton_Previews: PreviewProvider { + static var previews: some View { + Group { + VStack { + OptionButton(icon: Asset.Images.spaceTypeIcon.image, title: "A title", detailMessage: "Some details for this option", action: {}).theme(.light) + OptionButton(icon: nil, title: "A title", detailMessage: "Some details for this option", action: {}).theme(.light) + OptionButton(icon: nil, title: "A title", detailMessage: nil, action: {}).theme(.light) + } + VStack { + OptionButton(icon: Asset.Images.spaceTypeIcon.image, title: "A title", detailMessage: "Some details for this option", action: {}).theme(.dark) + OptionButton(icon: nil, title: "A title", detailMessage: "Some details for this option", action: {}).theme(.dark) + OptionButton(icon: nil, title: "A title", detailMessage: nil, action: {}).theme(.dark) + }.preferredColorScheme(.dark) + } + .padding() + } +} diff --git a/RiotSwiftUI/Modules/Common/Util/ResponderManager.swift b/RiotSwiftUI/Modules/Common/Util/ResponderManager.swift new file mode 100644 index 000000000..4dc23874c --- /dev/null +++ b/RiotSwiftUI/Modules/Common/Util/ResponderManager.swift @@ -0,0 +1,73 @@ +// +// 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 + +class ResponderManager { + + private static var tagIndex: Int = 1000 + private static var registeredResponders = NSMapTable(keyOptions: .strongMemory, valueOptions: .weakMemory) + + private static var nextIndex: Int { + tagIndex += 1 + return tagIndex + } + + private static var firstResponder: UIView? { + guard let enumerator = registeredResponders.objectEnumerator() else { + return nil + } + + while let view: UIView = enumerator.nextObject() as? UIView { + if view.isFirstResponder { + return view + } + } + + return nil + } + + static func register(view: UIView) { + view.tag = nextIndex + registeredResponders.setObject(view, forKey: NSNumber(value: view.tag)) + } + + static func unregister(view: UIView) { + registeredResponders.removeObject(forKey: NSNumber(value: view.tag)) + } + + static func makeActiveNextResponder() -> Bool { + guard let firstResponder = self.firstResponder else { + return false + } + + return makeActiveNextResponder(of: firstResponder) + } + + static func makeActiveNextResponder(of view: UIView) -> Bool { + let nextTag = view.tag + 1 + guard let nextResponder = registeredResponders.object(forKey: NSNumber(value: nextTag)) else { + return false + } + + nextResponder.becomeFirstResponder() + return true + } + + static func resignFirstResponder() { + firstResponder?.resignFirstResponder() + } +} diff --git a/RiotSwiftUI/Modules/Common/Util/RoundedBorderTextEditor.swift b/RiotSwiftUI/Modules/Common/Util/RoundedBorderTextEditor.swift new file mode 100644 index 000000000..cf66ebe5a --- /dev/null +++ b/RiotSwiftUI/Modules/Common/Util/RoundedBorderTextEditor.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 RoundedBorderTextEditor: View { + + // MARK: - Properties + + var title: String? + var placeHolder: String + @Binding var text: String + var textMaxHeight: CGFloat? + @Binding var error: String? + + var onTextChanged: ((String) -> Void)? + var onEditingChanged: ((Bool) -> Void)? + + @State private var editing = false + + // MARK: Private + + @Environment(\.theme) private var theme: ThemeSwiftUI + + // MARK: Public + + var body: some View { + VStack(alignment: .leading, spacing: -1) { + if let title = self.title { + Text(title) + .foregroundColor(theme.colors.primaryContent) + .font(theme.fonts.subheadline) + .multilineTextAlignment(.leading) + .padding(EdgeInsets(top: 0, leading: 0, bottom: 8, trailing: 0)) + } + ZStack(alignment: .topLeading) { + if text.isEmpty { + Text(placeHolder) + .padding(EdgeInsets(top: 10, leading: 10, bottom: 0, trailing: 0)) + .font(theme.fonts.callout) + .foregroundColor(theme.colors.tertiaryContent) + .allowsHitTesting(false) + } + ThemableTextEditor(text: $text, onEditingChanged: { edit in + self.editing = edit + onEditingChanged?(edit) + }) + .modifier(ClearViewModifier(alignment: .top, text: $text)) + .modifier(NextViewModifier(alignment: .bottomTrailing, isEditing: $editing)) + .padding(EdgeInsets(top: 2, leading: 6, bottom: 0, trailing: 0)) + .onChange(of: text, perform: { newText in + onTextChanged?(newText) + }) + } + .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) + if let error = self.error { + Text(error) + .foregroundColor(theme.colors.alert) + .font(theme.fonts.footnote) + .multilineTextAlignment(.leading) + .padding(EdgeInsets(top: 8, leading: 0, bottom: 0, trailing: 0)) + .transition(.opacity) + } + } + .animation(.easeOut(duration: 0.2), value: error) + } +} + +// MARK: - Previews + +@available(iOS 14.0, *) +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) + } + .padding() + } +} diff --git a/RiotSwiftUI/Modules/Common/Util/RoundedBorderTextField.swift b/RiotSwiftUI/Modules/Common/Util/RoundedBorderTextField.swift new file mode 100644 index 000000000..64daa187c --- /dev/null +++ b/RiotSwiftUI/Modules/Common/Util/RoundedBorderTextField.swift @@ -0,0 +1,109 @@ +// +// 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 RoundedBorderTextField: View { + + // MARK: - Properties + + var title: String? + var placeHolder: String + @Binding var text: String + @Binding var footerText: String? + @Binding var isError: Bool + + var configuration: UIKitTextInputConfiguration = UIKitTextInputConfiguration() + + var onTextChanged: ((String) -> Void)? + var onEditingChanged: ((Bool) -> Void)? + + // MARK: Private + + @State private var editing = false + + @Environment(\.theme) private var theme: ThemeSwiftUI + + // MARK: Public + + var body: some View { + VStack(alignment: .leading, spacing: -1) { + if let title = self.title { + Text(title) + .foregroundColor(theme.colors.primaryContent) + .font(theme.fonts.subheadline) + .multilineTextAlignment(.leading) + .padding(EdgeInsets(top: 0, leading: 0, bottom: 8, trailing: 0)) + } + ZStack(alignment: .leading) { + if text.isEmpty { + Text(placeHolder) + .font(theme.fonts.callout) + .foregroundColor(theme.colors.tertiaryContent) + .lineLimit(1) + } + ThemableTextField(placeholder: "", text: $text, configuration: configuration, onEditingChanged: { edit in + self.editing = edit + onEditingChanged?(edit) + }) + .onChange(of: text, perform: { newText in + onTextChanged?(newText) + }) + .modifier(ClearViewModifier(alignment: .center, text: $text)) + } + .padding(EdgeInsets(top: 8, leading: 8, bottom: 8, trailing: 0)) + .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)) + + if let footerText = self.footerText { + Text(footerText) + .foregroundColor(isError ? theme.colors.alert : theme.colors.tertiaryContent) + .font(theme.fonts.footnote) + .multilineTextAlignment(.leading) + .padding(EdgeInsets(top: 8, leading: 0, bottom: 0, trailing: 0)) + .transition(.opacity) + } + } + .animation(.easeOut(duration: 0.2)) + } +} + +// MARK: - Previews + +@available(iOS 14.0, *) +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) + } + .padding() + } +} diff --git a/RiotSwiftUI/Modules/Common/Util/SearchBar.swift b/RiotSwiftUI/Modules/Common/Util/SearchBar.swift new file mode 100644 index 000000000..f498c24e6 --- /dev/null +++ b/RiotSwiftUI/Modules/Common/Util/SearchBar.swift @@ -0,0 +1,81 @@ +// +// 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 SearchBar: View { + + // MARK: - Properties + + var placeholder: String + @Binding var text: String + + // MARK: - Private + + @Environment(\.theme) private var theme: ThemeSwiftUI + @State private var isEditing = false + var onTextChanged: ((String) -> Void)? + + // MARK: - Public + + var body: some View { + HStack { + TextField(placeholder, text: $text) { isEditing in + self.isEditing = isEditing + } + .padding(8) + .padding(.horizontal, 25) + .background(theme.colors.navigation) + .cornerRadius(8) + .padding(.leading) + .padding(.trailing, isEditing ? 8 : 16) + .overlay( + HStack { + Image(systemName: "magnifyingglass") + .renderingMode(.template) + .foregroundColor(theme.colors.quarterlyContent) + .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) + + if isEditing && !text.isEmpty { + Button(action: { + self.text = "" + }) { + Image(systemName: "multiply.circle.fill") + .renderingMode(.template) + .foregroundColor(theme.colors.quarterlyContent) + } + } + } + .padding(.horizontal, 22) + ) + if isEditing { + Button(action: { + self.isEditing = false + self.text = "" + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + }) { + Text(VectorL10n.cancel) + .font(theme.fonts.body) + } + .foregroundColor(theme.colors.accent) + .padding(.trailing) + .transition(.move(edge: .trailing)) + } + } + .animation(.default) + } +} diff --git a/RiotSwiftUI/Modules/Common/Util/ThemableButton.swift b/RiotSwiftUI/Modules/Common/Util/ThemableButton.swift new file mode 100644 index 000000000..313ee3f8e --- /dev/null +++ b/RiotSwiftUI/Modules/Common/Util/ThemableButton.swift @@ -0,0 +1,82 @@ +// +// 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 ThemableButton: View { + + // MARK: - Style + + private struct Style: ButtonStyle { + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .scaleEffect(configuration.isPressed ? 0.97 : 1) + .animation(.easeOut(duration: 0.2), value: configuration.isPressed) + } + } + + // MARK: - Properties + + let icon: UIImage? + let title: String + let action: () -> Void + + // MARK: Private + + @Environment(\.theme) private var theme: ThemeSwiftUI + + // MARK: Public + + var body: some View { + Button(action: action, label: { + HStack { + Spacer() + if let icon = self.icon { + Image(uiImage: icon).renderingMode(.template).resizable().frame(width: 24, height: 24).foregroundColor(theme.colors.background) + } + Text(title).font(theme.fonts.bodySB).foregroundColor(theme.colors.background) + Spacer() + } + .padding() + .background(theme.colors.accent) + .foregroundColor(theme.colors.background) + .clipShape(RoundedRectangle(cornerRadius: 8)) + }) + .buttonStyle(Style()) + .frame(height: 48) + } +} + +// MARK: - Previews + +@available(iOS 14.0, *) +struct ThemableButton_Previews: PreviewProvider { + static var previews: some View { + Group { + VStack(alignment: .center, spacing: 20) { + ThemableButton(icon: Asset.Images.spaceTypeIcon.image, title: "A title", action: {}).theme(.light).preferredColorScheme(.light) + ThemableButton(icon: nil, title: "A title", action: {}).theme(.light).preferredColorScheme(.light) + } + VStack(alignment: .center, spacing: 20) { + ThemableButton(icon: Asset.Images.spaceTypeIcon.image, title: "A title", action: {}).theme(.dark).preferredColorScheme(.dark) + ThemableButton(icon: nil, title: "A title", action: {}).theme(.dark).preferredColorScheme(.dark) + } + } + .padding() + } +} diff --git a/RiotSwiftUI/Modules/Common/Util/ThemableTextEditor.swift b/RiotSwiftUI/Modules/Common/Util/ThemableTextEditor.swift new file mode 100644 index 000000000..5a406b921 --- /dev/null +++ b/RiotSwiftUI/Modules/Common/Util/ThemableTextEditor.swift @@ -0,0 +1,147 @@ +// +// 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 ThemableTextEditor: UIViewRepresentable { + + @Environment(\.theme) private var theme: ThemeSwiftUI + + @Binding var text: String + @State var configuration: UIKitTextInputConfiguration = UIKitTextInputConfiguration() + var onEditingChanged: ((_ edit: Bool) -> Void)? + + private let textView: UITextView = UITextView() + private let nextButton: UIButton = UIButton(type: .custom) + private let internalParams = InternalParams() + + func makeUIView(context: Context) -> UITextView { + textView.delegate = context.coordinator + textView.text = text + + ResponderManager.register(view: textView) + + if internalParams.isFirstResponder { + textView.becomeFirstResponder() + } + + return textView + } + + func updateUIView(_ uiView: UITextView, context: Context) { + uiView.backgroundColor = .clear + uiView.font = UIFont.preferredFont(forTextStyle: .callout) + uiView.textColor = UIColor(theme.colors.primaryContent) + uiView.tintColor = UIColor(theme.colors.accent) + + if uiView.text != self.text { + uiView.text = self.text + } + + uiView.keyboardType = configuration.keyboardType + uiView.returnKeyType = configuration.returnKeyType + uiView.isSecureTextEntry = configuration.isSecureTextEntry + uiView.autocapitalizationType = configuration.autocapitalizationType + uiView.autocorrectionType = configuration.autocorrectionType + } + + static func dismantleUIView(_ uiView: UITextView, coordinator: Coordinator) { + ResponderManager.unregister(view: uiView) + } + + // MARK: - Private + + private func replaceText(with newText: String) { + self.text = newText + } + + private class InternalParams { + var isFirstResponder = false + } + + // MARK: - Coordinator + + func makeCoordinator() -> Coordinator { + return Coordinator(self) + } + + class Coordinator: NSObject, UITextViewDelegate { + var parent: ThemableTextEditor + + init(_ parent: ThemableTextEditor) { + self.parent = parent + } + + func textViewDidBeginEditing(_ textView: UITextView) { + parent.onEditingChanged?(true) + } + + func textViewDidEndEditing(_ textView: UITextView) { + parent.onEditingChanged?(false) + } + + func textViewDidChange(_ textView: UITextView) { + guard let text = textView.text else { + return + } + + parent.replaceText(with: text) + } + + @objc func wakeUpNextResponder() { + if !ResponderManager.makeActiveNextResponder(of: parent.textView) { + parent.textView.resignFirstResponder() + } + } + } +} + +// MARK: - modifiers + +@available(iOS 14.0, *) +extension ThemableTextEditor { + func keyboardType(_ type: UIKeyboardType) -> ThemableTextEditor { + textView.keyboardType = type + return self + } + + func isSecureTextEntry(_ isSecure: Bool) -> ThemableTextEditor { + textView.isSecureTextEntry = isSecure + return self + } + + func returnKeyType(_ type: UIReturnKeyType) -> ThemableTextEditor { + textView.returnKeyType = type + return self + } + + func autocapitalizationType(_ type: UITextAutocapitalizationType) -> ThemableTextEditor { + textView.autocapitalizationType = type + return self + } + + func autocorrectionType(_ type: UITextAutocorrectionType) -> ThemableTextEditor { + textView.autocorrectionType = type + return self + } + + func makeFirstResponder() -> ThemableTextEditor { + internalParams.isFirstResponder = true + return self + } +} diff --git a/RiotSwiftUI/Modules/Common/Util/ThemableTextField.swift b/RiotSwiftUI/Modules/Common/Util/ThemableTextField.swift new file mode 100644 index 000000000..c686db147 --- /dev/null +++ b/RiotSwiftUI/Modules/Common/Util/ThemableTextField.swift @@ -0,0 +1,138 @@ +// +// 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 UIKitTextInputConfiguration { + var keyboardType: UIKeyboardType = .default + var returnKeyType: UIReturnKeyType = .default + var isSecureTextEntry: Bool = false + var autocapitalizationType: UITextAutocapitalizationType = .sentences + var autocorrectionType: UITextAutocorrectionType = .default +} + +@available(iOS 14.0, *) +struct ThemableTextField: UIViewRepresentable { + + @Environment(\.theme) private var theme: ThemeSwiftUI + + @State var placeholder: String? + @Binding var text: String + @State var configuration: UIKitTextInputConfiguration = UIKitTextInputConfiguration() + var onEditingChanged: ((_ edit: Bool) -> Void)? + var onCommit: (() -> Void)? + + private let textField: UITextField = UITextField() + private let internalParams = InternalParams() + + func makeUIView(context: Context) -> UITextField { + textField.delegate = context.coordinator + textField.setContentHuggingPriority(.defaultHigh, for: .vertical) + textField.setContentHuggingPriority(.defaultLow, for: .horizontal) + textField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + textField.text = text + + textField.addTarget(context.coordinator, action: #selector(Coordinator.textFieldEditingChanged(sender:)), for: .editingChanged) + + ResponderManager.register(view: textField) + + if internalParams.isFirstResponder { + textField.becomeFirstResponder() + } + + return textField + } + + func updateUIView(_ uiView: UITextField, context: Context) { + uiView.backgroundColor = .clear + uiView.font = UIFont.preferredFont(forTextStyle: .callout) + uiView.textColor = UIColor(theme.colors.primaryContent) + uiView.tintColor = UIColor(theme.colors.accent) + + if uiView.text != self.text { + uiView.text = self.text + } + uiView.placeholder = placeholder + + uiView.keyboardType = configuration.keyboardType + uiView.returnKeyType = configuration.returnKeyType + uiView.isSecureTextEntry = configuration.isSecureTextEntry + uiView.autocapitalizationType = configuration.autocapitalizationType + uiView.autocorrectionType = configuration.autocorrectionType + } + + static func dismantleUIView(_ uiView: UITextField, coordinator: Coordinator) { + ResponderManager.unregister(view: uiView) + } + + // MARK: - Private + + private func replaceText(with newText: String) { + self.text = newText + } + + // MARK: - Coordinator + + func makeCoordinator() -> Coordinator { + return Coordinator(self) + } + + class Coordinator: NSObject, UITextFieldDelegate { + + var parent: ThemableTextField + + init(_ parent: ThemableTextField) { + self.parent = parent + } + + func textFieldDidBeginEditing(_ textField: UITextField) { + parent.onEditingChanged?(true) + } + + func textFieldDidEndEditing(_ textField: UITextField) { + parent.onEditingChanged?(false) + } + + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + if !ResponderManager.makeActiveNextResponder(of: textField) { + textField.resignFirstResponder() + } + + parent.onCommit?() + + return true + } + + @objc func textFieldEditingChanged(sender: UITextField) { + parent.replaceText(with: sender.text ?? "") + } + } + + private class InternalParams { + var isFirstResponder = false + } + +} + +// MARK: - modifiers + +@available(iOS 14.0, *) +extension ThemableTextField { + func makeFirstResponder() -> ThemableTextField { + internalParams.isFirstResponder = true + return self + } +} diff --git a/RiotSwiftUI/Modules/Common/Util/View+Riot.swift b/RiotSwiftUI/Modules/Common/Util/View+Riot.swift new file mode 100644 index 000000000..d126ec8d0 --- /dev/null +++ b/RiotSwiftUI/Modules/Common/Util/View+Riot.swift @@ -0,0 +1,28 @@ +// +// 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 13.0, *) +extension View { + @ViewBuilder func isHidden(_ isHidden: Bool) -> some View { + if isHidden { + self.hidden() + } else { + self + } + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/Coordinator/SpaceCreationCoordinator.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/Coordinator/SpaceCreationCoordinator.swift new file mode 100644 index 000000000..8eb84d9e0 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/Coordinator/SpaceCreationCoordinator.swift @@ -0,0 +1,242 @@ +// File created from TemplateAdvancedRoomsExample +// $ createSwiftUITwoScreen.sh Spaces/SpaceCreation SpaceCreation SpaceCreationMenu SpaceCreationSettings +// File created from FlowTemplate +// $ createRootCoordinator.sh SpaceCreationCoordinator SpaceCreation +/* + 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 + +@objcMembers +final class SpaceCreationCoordinator: Coordinator { + + // MARK: - Properties + + // MARK: Private + + private let parameters: SpaceCreationCoordinatorParameters + + private var navigationRouter: NavigationRouterType { + return self.parameters.navigationRouter + } + + private let spaceVisibilityMenuParameters: SpaceCreationMenuCoordinatorParameters + private let spaceSharingTypeMenuParameters: SpaceCreationMenuCoordinatorParameters + + // MARK: Public + + // Must be used only internally + var childCoordinators: [Coordinator] = [] + + var callback: ((SpaceCreationCoordinatorAction) -> Void)? + + // MARK: - Setup + + init(parameters: SpaceCreationCoordinatorParameters) { + self.parameters = parameters + self.spaceVisibilityMenuParameters = SpaceCreationMenuCoordinatorParameters( + session: parameters.session, + creationParams: parameters.creationParameters, + navTitle: VectorL10n.spacesCreateSpaceTitle, + title: VectorL10n.spacesCreationVisibilityTitle, + detail: VectorL10n.spacesCreationVisibilityMessage, + options: [ + SpaceCreationMenuRoomOption(id: .publicSpace, icon: Asset.Images.spaceTypeIcon.image, title: VectorL10n.spacePublicJoinRule, detail: VectorL10n.spacePublicJoinRuleDetail), + SpaceCreationMenuRoomOption(id: .privateSpace, icon: Asset.Images.spacePrivateIcon.image, title: VectorL10n.spacePrivateJoinRule, detail: VectorL10n.spacePrivateJoinRuleDetail) + ] + ) + + self.spaceSharingTypeMenuParameters = SpaceCreationMenuCoordinatorParameters( + session: parameters.session, + creationParams: parameters.creationParameters, + navTitle: nil, + title: VectorL10n.spacesCreationSharingTypeTitle, + detail: VectorL10n.spacesCreationSharingTypeMessage(parameters.creationParameters.name ?? ""), + options: [ + SpaceCreationMenuRoomOption(id: .ownedPrivateSpace, icon: Asset.Images.tabPeople.image, title: VectorL10n.spacesCreationSharingTypeJustMeTitle, detail: VectorL10n.spacesCreationSharingTypeJustMeDetail), + SpaceCreationMenuRoomOption(id: .sharedPrivateSpace, icon: Asset.Images.tabGroups.image, title: VectorL10n.spacesCreationSharingTypeMeAndTeammatesTitle, detail: VectorL10n.spacesCreationSharingTypeMeAndTeammatesDetail) + ] + ) + } + + // MARK: - Public + + func start() { + if #available(iOS 14.0, *) { + MXLog.debug("[SpaceCreationCoordinator] did start.") + + let rootCoordinator = self.createMenuCoordinator(with: spaceVisibilityMenuParameters) + rootCoordinator.start() + + self.add(childCoordinator: rootCoordinator) + + self.toPresentable().isModalInPresentation = true + + 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() + } + + @available(iOS 14.0, *) + private func createMenuCoordinator(with parameters: SpaceCreationMenuCoordinatorParameters) -> SpaceCreationMenuCoordinator { + let coordinator: SpaceCreationMenuCoordinator = SpaceCreationMenuCoordinator(parameters: parameters) + + coordinator.callback = { [weak self] result in + MXLog.debug("[SpaceCreationCoordinator] SpaceCreationMenuCoordinator did complete with result \(result).") + guard let self = self else { return } + switch result { + case .didSelectOption(let optionId): + switch optionId { + case .privateSpace, .publicSpace: + self.pushScreen(with: self.createSettingsCoordinator()) + case .ownedPrivateSpace: + self.pushScreen(with: self.createRoomChooserCoordinator()) + case .sharedPrivateSpace: + self.pushScreen(with: self.createRoomsCoordinator()) + } + case .cancel: + self.callback?(.cancel) + } + } + return coordinator + } + + @available(iOS 14.0, *) + private func createSettingsCoordinator() -> SpaceCreationSettingsCoordinator { + let coordinator = SpaceCreationSettingsCoordinator(parameters: SpaceCreationSettingsCoordinatorParameters(session: parameters.session, creationParameters: parameters.creationParameters)) + coordinator.callback = { [weak self] result in + guard let self = self else { return } + switch result { + case .didSetupParameters: + if self.parameters.creationParameters.isPublic { + self.pushScreen(with: self.createRoomsCoordinator()) + } else { + self.pushScreen(with: self.createMenuCoordinator(with: self.spaceSharingTypeMenuParameters)) + } + case .cancel: + self.callback?(.cancel) + } + } + return coordinator + } + + @available(iOS 14.0, *) + private func createRoomsCoordinator() -> SpaceCreationRoomsCoordinator { + let coordinator = SpaceCreationRoomsCoordinator(parameters: SpaceCreationRoomsCoordinatorParameters(session: parameters.session, creationParams: parameters.creationParameters)) + coordinator.callback = { [weak self] result in + guard let self = self else { return } + switch result { + case .didSetupRooms: + if self.parameters.creationParameters.isPublic { + self.pushScreen(with: self.createPostProcessCoordinator()) + } else if self.parameters.creationParameters.isShared { + self.pushScreen(with: self.createEmailInvitesCoordinator()) + } else { + UILog.error("[SpaceCreationCoordinator] createRoomsCoordinator: should be public space or shared private space") + } + case .cancel: + self.callback?(.cancel) + } + } + return coordinator + } + + @available(iOS 14.0, *) + private func createEmailInvitesCoordinator() -> SpaceCreationEmailInvitesCoordinator { + let coordinator = SpaceCreationEmailInvitesCoordinator(parameters: SpaceCreationEmailInvitesCoordinatorParameters(session: parameters.session, creationParams: parameters.creationParameters)) + coordinator.callback = { [weak self] result in + guard let self = self else { return } + switch result { + case .cancel: + self.callback?(.cancel) + case .done: + self.pushScreen(with: self.createPostProcessCoordinator()) + case .inviteByUsername: + self.pushScreen(with: self.createPeopleChooserCoordinator()) + } + } + return coordinator + } + + @available(iOS 14.0, *) + private func createPeopleChooserCoordinator() -> SpaceCreationMatrixItemChooserCoordinator { + let coordinator = SpaceCreationMatrixItemChooserCoordinator(parameters: SpaceCreationMatrixItemChooserCoordinatorParameters(session: parameters.session, type: .people, creationParams: parameters.creationParameters)) + coordinator.callback = { [weak self] result in + guard let self = self else { return } + switch result { + case .cancel: + self.callback?(.cancel) + case .done: + self.pushScreen(with: self.createPostProcessCoordinator()) + } + } + return coordinator + } + + @available(iOS 14.0, *) + private func createRoomChooserCoordinator() -> SpaceCreationMatrixItemChooserCoordinator { + let coordinator = SpaceCreationMatrixItemChooserCoordinator(parameters: SpaceCreationMatrixItemChooserCoordinatorParameters(session: parameters.session, type: .room, creationParams: parameters.creationParameters)) + coordinator.callback = { [weak self] result in + guard let self = self else { return } + switch result { + case .cancel: + self.callback?(.cancel) + case .done: + self.pushScreen(with: self.createPostProcessCoordinator()) + } + } + return coordinator + } + + @available(iOS 14.0, *) + private func createPostProcessCoordinator() -> SpaceCreationPostProcessCoordinator { + let coordinator = SpaceCreationPostProcessCoordinator(parameters: SpaceCreationPostProcessCoordinatorParameters(session: parameters.session, creationParams: parameters.creationParameters)) + coordinator.callback = { [weak self] result in + guard let self = self else { return } + switch result { + case .done(let spaceId): + self.callback?(.done(spaceId)) + case .cancel: + self.callback?(.cancel) + } + } + return coordinator + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/Coordinator/SpaceCreationCoordinatorAction.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/Coordinator/SpaceCreationCoordinatorAction.swift new file mode 100644 index 000000000..879d8e0f8 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/Coordinator/SpaceCreationCoordinatorAction.swift @@ -0,0 +1,22 @@ +// +// 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 + +enum SpaceCreationCoordinatorAction { + case cancel + case done(_ spaceId: String) +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/Coordinator/SpaceCreationCoordinatorParameters.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/Coordinator/SpaceCreationCoordinatorParameters.swift new file mode 100644 index 000000000..0106cef34 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/Coordinator/SpaceCreationCoordinatorParameters.swift @@ -0,0 +1,40 @@ +// File created from TemplateAdvancedRoomsExample +// $ createSwiftUITwoScreen.sh Spaces/SpaceCreation SpaceCreation SpaceCreationMenu SpaceCreationSettings +// File created from FlowTemplate +// $ createRootCoordinator.sh SpaceCreationCoordinator SpaceCreation +/* + 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 + +/// SpaceCreationCoordinator input parameters +struct SpaceCreationCoordinatorParameters { + + /// The Matrix session + let session: MXSession + + /// Parameters needed to create the new space + let creationParameters: SpaceCreationParameters = SpaceCreationParameters() + + /// The navigation router that manage physical navigation + let navigationRouter: NavigationRouterType + + init(session: MXSession, + navigationRouter: NavigationRouterType? = nil) { + self.session = session + self.navigationRouter = navigationRouter ?? NavigationRouter(navigationController: RiotNavigationController()) + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Coordinator/SpaceCreationEmailInvitesCoordinator.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Coordinator/SpaceCreationEmailInvitesCoordinator.swift new file mode 100644 index 000000000..6972bd39f --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Coordinator/SpaceCreationEmailInvitesCoordinator.swift @@ -0,0 +1,74 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationEmailInvites SpaceCreationEmailInvites +/* + 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 UIKit +import SwiftUI + +final class SpaceCreationEmailInvitesCoordinator: Coordinator, Presentable { + + // MARK: - Properties + + // MARK: Private + + private let parameters: SpaceCreationEmailInvitesCoordinatorParameters + private let spaceCreationEmailInvitesHostingController: UIViewController + private var spaceCreationEmailInvitesViewModel: SpaceCreationEmailInvitesViewModelProtocol + + // MARK: Public + + // Must be used only internally + var childCoordinators: [Coordinator] = [] + var callback: ((SpaceCreationEmailInvitesCoordinatorAction) -> Void)? + + // MARK: - Setup + + @available(iOS 14.0, *) + init(parameters: SpaceCreationEmailInvitesCoordinatorParameters) { + self.parameters = parameters + let service = SpaceCreationEmailInvitesService() + let viewModel = SpaceCreationEmailInvitesViewModel(creationParameters: parameters.creationParams, service: service) + let view = SpaceCreationEmailInvites(viewModel: viewModel.context) + .addDependency(AvatarService.instantiate(mediaManager: parameters.session.mediaManager)) + spaceCreationEmailInvitesViewModel = viewModel + let hostingController = VectorHostingController(rootView: view) + hostingController.hidesBackTitleWhenPushed = true + spaceCreationEmailInvitesHostingController = hostingController + } + + // MARK: - Public + func start() { + MXLog.debug("[SpaceCreationEmailInvitesCoordinator] did start.") + spaceCreationEmailInvitesViewModel.completion = { [weak self] result in + MXLog.debug("[SpaceCreationEmailInvitesCoordinator] SpaceCreationEmailInvitesViewModel did complete with result: \(result).") + guard let self = self else { return } + switch result { + case .cancel: + self.callback?(.cancel) + case .done: + self.callback?(.done) + case .inviteByUsername: + self.callback?(.inviteByUsername) + } + } + } + + func toPresentable() -> UIViewController { + return self.spaceCreationEmailInvitesHostingController + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Coordinator/SpaceCreationEmailInvitesCoordinatorParameters.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Coordinator/SpaceCreationEmailInvitesCoordinatorParameters.swift new file mode 100644 index 000000000..af2ac696c --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Coordinator/SpaceCreationEmailInvitesCoordinatorParameters.swift @@ -0,0 +1,24 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationEmailInvites SpaceCreationEmailInvites +// +// 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 + +struct SpaceCreationEmailInvitesCoordinatorParameters { + let session: MXSession + let creationParams: SpaceCreationParameters +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Model/SpaceCreationEmailInvitesCoordinatorAction.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Model/SpaceCreationEmailInvitesCoordinatorAction.swift new file mode 100644 index 000000000..2eab43809 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Model/SpaceCreationEmailInvitesCoordinatorAction.swift @@ -0,0 +1,23 @@ +// +// 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 + +enum SpaceCreationEmailInvitesCoordinatorAction { + case done + case cancel + case inviteByUsername +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Model/SpaceCreationEmailInvitesPresence.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Model/SpaceCreationEmailInvitesPresence.swift new file mode 100644 index 000000000..c5b6e9df5 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Model/SpaceCreationEmailInvitesPresence.swift @@ -0,0 +1,44 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationEmailInvites SpaceCreationEmailInvites +// +// 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 + +enum SpaceCreationEmailInvitesPresence { + case online + case idle + case offline +} + +extension SpaceCreationEmailInvitesPresence { + var title: String { + switch self { + case .online: + return VectorL10n.roomParticipantsOnline + case .idle: + return VectorL10n.roomParticipantsIdle + case .offline: + return VectorL10n.roomParticipantsOffline + } + } +} + +extension SpaceCreationEmailInvitesPresence: CaseIterable { } + +extension SpaceCreationEmailInvitesPresence: Identifiable { + var id: Self { self } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Model/SpaceCreationEmailInvitesStateAction.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Model/SpaceCreationEmailInvitesStateAction.swift new file mode 100644 index 000000000..ad6cbf897 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Model/SpaceCreationEmailInvitesStateAction.swift @@ -0,0 +1,23 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationEmailInvites SpaceCreationEmailInvites +// +// 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 + +enum SpaceCreationEmailInvitesStateAction { + case updateEmailValidity(_ validity: [Bool]) +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Model/SpaceCreationEmailInvitesViewAction.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Model/SpaceCreationEmailInvitesViewAction.swift new file mode 100644 index 000000000..38df3f06b --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Model/SpaceCreationEmailInvitesViewAction.swift @@ -0,0 +1,25 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationEmailInvites SpaceCreationEmailInvites +// +// 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 + +enum SpaceCreationEmailInvitesViewAction { + case cancel + case done + case inviteByUsername +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Model/SpaceCreationEmailInvitesViewModelBindings.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Model/SpaceCreationEmailInvitesViewModelBindings.swift new file mode 100644 index 000000000..531689fa1 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Model/SpaceCreationEmailInvitesViewModelBindings.swift @@ -0,0 +1,21 @@ +// +// 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 + +struct SpaceCreationEmailInvitesViewModelBindings { + var emailInvites: [String] +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Model/SpaceCreationEmailInvitesViewModelResult.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Model/SpaceCreationEmailInvitesViewModelResult.swift new file mode 100644 index 000000000..178cc76a3 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Model/SpaceCreationEmailInvitesViewModelResult.swift @@ -0,0 +1,25 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationEmailInvites SpaceCreationEmailInvites +// +// 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 + +enum SpaceCreationEmailInvitesViewModelResult { + case cancel + case done + case inviteByUsername +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Model/SpaceCreationEmailInvitesViewState.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Model/SpaceCreationEmailInvitesViewState.swift new file mode 100644 index 000000000..3db25e288 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Model/SpaceCreationEmailInvitesViewState.swift @@ -0,0 +1,25 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationEmailInvites SpaceCreationEmailInvites +// +// 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 + +struct SpaceCreationEmailInvitesViewState: BindableState { + var title: String + var emailAddressesValid: [Bool] + var bindings: SpaceCreationEmailInvitesViewModelBindings +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Service/MatrixSDK/SpaceCreationEmailInvitesService.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Service/MatrixSDK/SpaceCreationEmailInvitesService.swift new file mode 100644 index 000000000..e2810a831 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Service/MatrixSDK/SpaceCreationEmailInvitesService.swift @@ -0,0 +1,29 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationEmailInvites SpaceCreationEmailInvites +// +// 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 SpaceCreationEmailInvitesService: SpaceCreationEmailInvitesServiceProtocol { + + func validate(_ emailAddresses: [String]) -> [Bool] { + return emailAddresses.map { $0.isEmpty || MXTools.isEmailAddress($0) } + } + +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Service/Mock/MockSpaceCreationEmailInvitesScreenState.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Service/Mock/MockSpaceCreationEmailInvitesScreenState.swift new file mode 100644 index 000000000..29db62840 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Service/Mock/MockSpaceCreationEmailInvitesScreenState.swift @@ -0,0 +1,67 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationEmailInvites SpaceCreationEmailInvites +// +// 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 MockSpaceCreationEmailInvitesScreenState: MockScreenState, CaseIterable { + // A case for each state you want to represent + // with specific, minimal associated data that will allow you + // mock that screen. + case defaultEmailValues + case emailEntered + case emailValidationFailed + + /// The associated screen + var screenType: Any.Type { + SpaceCreationEmailInvites.self + } + + /// A list of screen state definitions + static var allCases: [MockSpaceCreationEmailInvitesScreenState] { + [.defaultEmailValues, .emailEntered, .emailValidationFailed] + } + + /// Generate the view struct for the screen state. + var screenView: ([Any], AnyView) { + let creationParams = SpaceCreationParameters() + let service: MockSpaceCreationEmailInvitesService + switch self { + case .defaultEmailValues: + service = MockSpaceCreationEmailInvitesService(defaultValidation: true) + case .emailEntered: + creationParams.emailInvites = ["test1@element.io", "test2@element.io"] + service = MockSpaceCreationEmailInvitesService(defaultValidation: true) + case .emailValidationFailed: + creationParams.emailInvites = ["test1@element.io", "test2@element.io"] + service = MockSpaceCreationEmailInvitesService(defaultValidation: false) + } + let viewModel = SpaceCreationEmailInvitesViewModel(creationParameters: creationParams, service: service) + + // can simulate service and viewModel actions here if needs be. + + return ( + [viewModel], + AnyView(SpaceCreationEmailInvites(viewModel: viewModel.context) + .addDependency(MockAvatarService.example)) + ) + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Service/Mock/MockSpaceCreationEmailInvitesService.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Service/Mock/MockSpaceCreationEmailInvitesService.swift new file mode 100644 index 000000000..6cc1275d0 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Service/Mock/MockSpaceCreationEmailInvitesService.swift @@ -0,0 +1,33 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationEmailInvites SpaceCreationEmailInvites +// +// 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 MockSpaceCreationEmailInvitesService: SpaceCreationEmailInvitesServiceProtocol { + private let defaultValidation: Bool + + init(defaultValidation: Bool) { + self.defaultValidation = defaultValidation + } + + func validate(_ emailAddresses: [String]) -> [Bool] { + return emailAddresses.map { _ in defaultValidation } + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Service/SpaceCreationEmailInvitesServiceProtocol.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Service/SpaceCreationEmailInvitesServiceProtocol.swift new file mode 100644 index 000000000..ca8026bb8 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Service/SpaceCreationEmailInvitesServiceProtocol.swift @@ -0,0 +1,25 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationEmailInvites SpaceCreationEmailInvites +// +// 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, *) +protocol SpaceCreationEmailInvitesServiceProtocol { + func validate(_ emailAddresses: [String]) -> [Bool] +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Test/UI/SpaceCreationEmailInvitesUITests.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Test/UI/SpaceCreationEmailInvitesUITests.swift new file mode 100644 index 000000000..fb8b66415 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Test/UI/SpaceCreationEmailInvitesUITests.swift @@ -0,0 +1,49 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationEmailInvites SpaceCreationEmailInvites +// +// 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 SpaceCreationEmailInvitesUITests: MockScreenTest { + + override class var screenType: MockScreenState.Type { + return MockSpaceCreationEmailInvitesScreenState.self + } + + override class func createTest() -> MockScreenTest { + return SpaceCreationEmailInvitesUITests(selector: #selector(verifySpaceCreationEmailInvitesScreen)) + } + + func verifySpaceCreationEmailInvitesScreen() throws { + guard let screenState = screenState as? MockSpaceCreationEmailInvitesScreenState else { fatalError("no screen") } + switch screenState { + case .defaultEmailValues: + verifyEmailValues() + case .emailEntered: + verifyEmailValues() + case .emailValidationFailed: + verifyEmailValues() + } + } + + func verifyEmailValues() { + let emailTextFieldsCount = app.textFields.matching(identifier: "emailTextField").count + XCTAssertEqual(emailTextFieldsCount, 2) + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Test/Unit/SpaceCreationEmailInvitesViewModelTests.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Test/Unit/SpaceCreationEmailInvitesViewModelTests.swift new file mode 100644 index 000000000..48116f1d7 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Test/Unit/SpaceCreationEmailInvitesViewModelTests.swift @@ -0,0 +1,41 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationEmailInvites SpaceCreationEmailInvites +// +// 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 SpaceCreationEmailInvitesViewModelTests: XCTestCase { + var creationParameters = SpaceCreationParameters() + var service: MockSpaceCreationEmailInvitesService! + var viewModel: SpaceCreationEmailInvitesViewModelProtocol! + var context: SpaceCreationEmailInvitesViewModelType.Context! + + override func setUpWithError() throws { + service = MockSpaceCreationEmailInvitesService() + viewModel = SpaceCreationEmailInvitesViewModel(creationParameters: creationParameters, service: service) + context = viewModel.context + } + + func testInitialState() { + XCTAssertEqual(context.viewState.title, creationParameters.isPublic ? VectorL10n.spacesCreationPublicSpaceTitle : VectorL10n.spacesCreationPrivateSpaceTitle) + XCTAssertEqual(context.emailInvites, creationParameters.emailInvites) + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/View/SpaceCreationEmailInvites.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/View/SpaceCreationEmailInvites.swift new file mode 100644 index 000000000..8fb6b1608 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/View/SpaceCreationEmailInvites.swift @@ -0,0 +1,124 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationEmailInvites SpaceCreationEmailInvites +// +// 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 SpaceCreationEmailInvites: View { + + // MARK: - Properties + + @ObservedObject var viewModel: SpaceCreationEmailInvitesViewModel.Context + + // MARK: - Private + + @Environment(\.theme) private var theme: ThemeSwiftUI + + // MARK: - Public + + @ViewBuilder + var body: some View { + VStack { + headerView + GeometryReader { reader in + ScrollView { + VStack { + Spacer() + formView + } + .frame(minHeight: reader.size.height - 2) + } + } + footerView + } + .padding(EdgeInsets(top: 24, leading: 16, bottom: 24, trailing: 16)) + .background(theme.colors.background) + .navigationTitle(viewModel.viewState.title) + .configureNavigationBar{ + $0.navigationBar.shadowImage = UIImage() + $0.navigationBar.barTintColor = UIColor(theme.colors.background) + $0.navigationBar.tintColor = UIColor(theme.colors.secondaryContent) + } + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button(action: { + viewModel.send(viewAction: .cancel) + }) { + Image(uiImage: Asset.Images.spacesModalClose.image).renderingMode(.template) + } + } + } + } + + // MARK: - Private + + @ViewBuilder + private var headerView: some View { + VStack { + Text(VectorL10n.spacesCreationEmailInvitesTitle) + .multilineTextAlignment(.center) + .font(theme.fonts.title3SB) + .foregroundColor(theme.colors.primaryContent) + Spacer().frame(height: 20) + Text(VectorL10n.spacesCreationEmailInvitesMessage) + .multilineTextAlignment(.center) + .font(theme.fonts.body) + .foregroundColor(theme.colors.secondaryContent) + } + } + + @ViewBuilder + private var formView: some View { + VStack { + VStack { + ForEach(viewModel.emailInvites.indices) { index in + RoundedBorderTextField(title: VectorL10n.spacesCreationEmailInvitesEmailTitle, placeHolder: VectorL10n.spacesCreationEmailInvitesEmailTitle, text: $viewModel.emailInvites[index], footerText: .constant(viewModel.viewState.emailAddressesValid[index] ? nil : VectorL10n.authInvalidEmail), isError: .constant(!viewModel.viewState.emailAddressesValid[index]), configuration: UIKitTextInputConfiguration(keyboardType: .emailAddress, returnKeyType: index < viewModel.emailInvites.endIndex - 1 ? .next : .done, autocapitalizationType: .none, autocorrectionType: .no)) + .accessibility(identifier: "emailTextField") + } + } + .padding(.horizontal, 2) + .padding(.bottom) + Text(VectorL10n.or) + .font(theme.fonts.caption1) + .foregroundColor(theme.colors.secondaryContent) + .padding(.bottom) + OptionButton(icon: Asset.Images.spacesInviteUsers.image, title: VectorL10n.spacesCreationInviteByUsername, detailMessage: nil) { + viewModel.send(viewAction: .inviteByUsername) + } + .padding(.bottom) + } + } + + @ViewBuilder + private var footerView: some View { + ThemableButton(icon: nil, title: VectorL10n.next) { + viewModel.send(viewAction: .done) + } + } +} + +// MARK: - Previews + +@available(iOS 14.0, *) +struct SpaceCreationEmailInvites_Previews: PreviewProvider { + static let stateRenderer = MockSpaceCreationEmailInvitesScreenState.stateRenderer + static var previews: some View { + stateRenderer.screenGroup(addNavigation: true).theme(.light).preferredColorScheme(.light) + stateRenderer.screenGroup(addNavigation: true).theme(.dark).preferredColorScheme(.dark) + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/ViewModel/SpaceCreationEmailInvitesViewModel.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/ViewModel/SpaceCreationEmailInvitesViewModel.swift new file mode 100644 index 000000000..99fbf4c3e --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/ViewModel/SpaceCreationEmailInvitesViewModel.swift @@ -0,0 +1,95 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationEmailInvites SpaceCreationEmailInvites +// +// 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 SpaceCreationEmailInvitesViewModelType = StateStoreViewModel +@available(iOS 14, *) +class SpaceCreationEmailInvitesViewModel: SpaceCreationEmailInvitesViewModelType, SpaceCreationEmailInvitesViewModelProtocol { + + // MARK: - Properties + + // MARK: Private + + private let creationParameters: SpaceCreationParameters + private let service: SpaceCreationEmailInvitesServiceProtocol + + // MARK: Public + + var completion: ((SpaceCreationEmailInvitesViewModelResult) -> Void)? + + // MARK: - Setup + + init(creationParameters: SpaceCreationParameters, service: SpaceCreationEmailInvitesServiceProtocol) { + self.creationParameters = creationParameters + self.service = service + let emailValidation = service.validate(creationParameters.emailInvites) + super.init(initialViewState: SpaceCreationEmailInvitesViewModel.defaultState(creationParameters: creationParameters, emailValidation: emailValidation)) + } + + private static func defaultState(creationParameters: SpaceCreationParameters, emailValidation: [Bool]) -> SpaceCreationEmailInvitesViewState { + let bindings = SpaceCreationEmailInvitesViewModelBindings(emailInvites: creationParameters.emailInvites) + return SpaceCreationEmailInvitesViewState( + title: creationParameters.isPublic ? VectorL10n.spacesCreationPublicSpaceTitle : VectorL10n.spacesCreationPrivateSpaceTitle, + emailAddressesValid: emailValidation, + bindings: bindings + ) + } + + // MARK: - Public + + override func process(viewAction: SpaceCreationEmailInvitesViewAction) { + switch viewAction { + case .cancel: + cancel() + case .done: + done() + case .inviteByUsername: + inviteByUsername() + } + } + + override class func reducer(state: inout SpaceCreationEmailInvitesViewState, action: SpaceCreationEmailInvitesStateAction) { + switch action { + case .updateEmailValidity(let emailValidity): + state.emailAddressesValid = emailValidity + } + } + + private func done() { + self.creationParameters.emailInvites = self.context.emailInvites + let emailAddressesValidity = service.validate(self.context.emailInvites) + + dispatch(action: .updateEmailValidity(emailAddressesValidity)) + if emailAddressesValidity.reduce(true, { $0 && $1}) { + completion?(.done) + } + } + + private func cancel() { + completion?(.cancel) + } + + private func inviteByUsername() { + completion?(.inviteByUsername) + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/ViewModel/SpaceCreationEmailInvitesViewModelProtocol.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/ViewModel/SpaceCreationEmailInvitesViewModelProtocol.swift new file mode 100644 index 000000000..a34305d9b --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/ViewModel/SpaceCreationEmailInvitesViewModelProtocol.swift @@ -0,0 +1,26 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationEmailInvites SpaceCreationEmailInvites +// +// 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 SpaceCreationEmailInvitesViewModelProtocol { + + var completion: ((SpaceCreationEmailInvitesViewModelResult) -> Void)? { get set } + @available(iOS 14, *) + var context: SpaceCreationEmailInvitesViewModelType.Context { get } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Coordinator/SpaceCreationMatrixItemChooserCoordinator.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Coordinator/SpaceCreationMatrixItemChooserCoordinator.swift new file mode 100644 index 000000000..d714f2b5d --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Coordinator/SpaceCreationMatrixItemChooserCoordinator.swift @@ -0,0 +1,72 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationMatrixItemChooser SpaceCreationMatrixItemChooser +/* + 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 UIKit +import SwiftUI + +final class SpaceCreationMatrixItemChooserCoordinator: Coordinator, Presentable { + + // MARK: - Properties + + // MARK: Private + + private let parameters: SpaceCreationMatrixItemChooserCoordinatorParameters + private let spaceCreationMatrixItemChooserHostingController: UIViewController + private var spaceCreationMatrixItemChooserViewModel: SpaceCreationMatrixItemChooserViewModelProtocol + + // MARK: Public + + // Must be used only internally + var childCoordinators: [Coordinator] = [] + var callback: ((SpaceCreationMatrixItemChooserCoordinatorAction) -> Void)? + + // MARK: - Setup + + @available(iOS 14.0, *) + init(parameters: SpaceCreationMatrixItemChooserCoordinatorParameters) { + self.parameters = parameters + let service = SpaceCreationMatrixItemChooserService(session: parameters.session, type: parameters.type, selectedItemIds: []) + let viewModel = SpaceCreationMatrixItemChooserViewModel.makeSpaceCreationMatrixItemChooserViewModel(spaceCreationMatrixItemChooserService: service, creationParams: parameters.creationParams) + let view = SpaceCreationMatrixItemChooser(viewModel: viewModel.context) + .addDependency(AvatarService.instantiate(mediaManager: parameters.session.mediaManager)) + spaceCreationMatrixItemChooserViewModel = viewModel + let hostingController = VectorHostingController(rootView: view) + hostingController.hidesBackTitleWhenPushed = true + spaceCreationMatrixItemChooserHostingController = hostingController + } + + // MARK: - Public + func start() { + MXLog.debug("[SpaceCreationMatrixItemChooserCoordinator] did start.") + spaceCreationMatrixItemChooserViewModel.callback = { [weak self] result in + MXLog.debug("[SpaceCreationMatrixItemChooserCoordinator] SpaceCreationMatrixItemChooserViewModel did complete with result: \(result).") + guard let self = self else { return } + switch result { + case .cancel: + self.callback?(.cancel) + case .done: + self.callback?(.done) + } + } + } + + func toPresentable() -> UIViewController { + return self.spaceCreationMatrixItemChooserHostingController + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Coordinator/SpaceCreationMatrixItemChooserCoordinatorParameters.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Coordinator/SpaceCreationMatrixItemChooserCoordinatorParameters.swift new file mode 100644 index 000000000..638c48ed1 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Coordinator/SpaceCreationMatrixItemChooserCoordinatorParameters.swift @@ -0,0 +1,25 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationMatrixItemChooser SpaceCreationMatrixItemChooser +// +// 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 + +struct SpaceCreationMatrixItemChooserCoordinatorParameters { + let session: MXSession + let type: SpaceCreationMatrixItemType + let creationParams: SpaceCreationParameters +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Model/SpaceCreationMatrixItem.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Model/SpaceCreationMatrixItem.swift new file mode 100644 index 000000000..6562518d5 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Model/SpaceCreationMatrixItem.swift @@ -0,0 +1,26 @@ +// +// 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 + +struct SpaceCreationMatrixItem { + let id: String + let avatar: AvatarInput + let displayName: String? + let detailText: String? +} + +extension SpaceCreationMatrixItem: Identifiable, Equatable {} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Model/SpaceCreationMatrixItemChooserCoordinatorAction.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Model/SpaceCreationMatrixItemChooserCoordinatorAction.swift new file mode 100644 index 000000000..a0788db52 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Model/SpaceCreationMatrixItemChooserCoordinatorAction.swift @@ -0,0 +1,23 @@ +// +// 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 + +/// Actions returned by the coordinator callback +enum SpaceCreationMatrixItemChooserCoordinatorAction { + case done + case cancel +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Model/SpaceCreationMatrixItemListStateAction.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Model/SpaceCreationMatrixItemListStateAction.swift new file mode 100644 index 000000000..daa989ccf --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Model/SpaceCreationMatrixItemListStateAction.swift @@ -0,0 +1,23 @@ +// +// 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 + +/// Actions to be performed on the `ViewModel` State +enum SpaceCreationMatrixItemListStateAction { + case updateItems([SpaceCreationMatrixItem]) + case updateSelection(Set) +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Model/SpaceCreationMatrixItemListStateActionListViewAction.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Model/SpaceCreationMatrixItemListStateActionListViewAction.swift new file mode 100644 index 000000000..2bc7e40c3 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Model/SpaceCreationMatrixItemListStateActionListViewAction.swift @@ -0,0 +1,25 @@ +// +// 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 + +/// Actions send from the `View` to the `ViewModel`. +enum SpaceCreationMatrixItemListStateActionListViewAction { + case searchTextChanged(String) + case itemTapped(_ itemId: String) + case done + case cancel +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Model/SpaceCreationMatrixItemListStateActionListViewModelAction.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Model/SpaceCreationMatrixItemListStateActionListViewModelAction.swift new file mode 100644 index 000000000..bfb31b2dd --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Model/SpaceCreationMatrixItemListStateActionListViewModelAction.swift @@ -0,0 +1,23 @@ +// +// 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 + +/// Actions sent by the`ViewModel` to the `Coordinator`. +enum SpaceCreationMatrixItemListStateActionListViewModelAction { + case done + case cancel +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Model/SpaceCreationMatrixItemListStateActionListViewState.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Model/SpaceCreationMatrixItemListStateActionListViewState.swift new file mode 100644 index 000000000..adf7e24cf --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Model/SpaceCreationMatrixItemListStateActionListViewState.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 + +/// State managed by the `ViewModel` delivered to the `View`. +struct SpaceCreationMatrixItemListStateActionListViewState: BindableState { + var navTitle: String + var title: String + var message: String + var emptyListMessage: String + var items: [SpaceCreationMatrixItem] + var selectedItemIds: Set +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Model/SpaceCreationMatrixItemType.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Model/SpaceCreationMatrixItemType.swift new file mode 100644 index 000000000..63b7068e2 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Model/SpaceCreationMatrixItemType.swift @@ -0,0 +1,22 @@ +// +// 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 + +enum SpaceCreationMatrixItemType { + case room + case people +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Service/MatrixSDK/SpaceCreationMatrixItemChooserService.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Service/MatrixSDK/SpaceCreationMatrixItemChooserService.swift new file mode 100644 index 000000000..64eb73ae9 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Service/MatrixSDK/SpaceCreationMatrixItemChooserService.swift @@ -0,0 +1,110 @@ +// +// 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 SpaceCreationMatrixItemChooserService: SpaceCreationMatrixItemChooserServiceProtocol { + + // MARK: - Properties + + // MARK: Private + + private let processingQueue = DispatchQueue(label: "org.matrix.element.SpaceCreationMatrixItemChooserService.processingQueue") + private let completionQueue = DispatchQueue.main + + private let session: MXSession + private let items: [SpaceCreationMatrixItem] + private var filteredItems: [SpaceCreationMatrixItem] { + didSet { + itemsSubject.send(filteredItems) + } + } + private var selectedItemIds: Set + + // MARK: Public + + private(set) var type: SpaceCreationMatrixItemType + private(set) var itemsSubject: CurrentValueSubject<[SpaceCreationMatrixItem], Never> + private(set) var selectedItemIdsSubject: CurrentValueSubject, Never> + var searchText: String = "" { + didSet { + if searchText.isEmpty { + filteredItems = items + } else { + self.processingQueue.async { + let lowercasedSearchText = self.searchText.lowercased() + let filteredItems = self.items.filter { $0.id.lowercased().contains(lowercasedSearchText) || ($0.displayName ?? "").lowercased().contains(lowercasedSearchText) } + + self.completionQueue.async { + self.filteredItems = filteredItems + } + } + } + } + } + + // MARK: - Setup + + init(session: MXSession, type: SpaceCreationMatrixItemType, selectedItemIds: [String]) { + self.session = session + self.type = type + switch type { + case .people: + self.items = session.users().map { user in + SpaceCreationMatrixItem(mxUser: user) + } + case .room: + self.items = session.rooms.compactMap { room in + if room.summary.roomType == .space || room.isDirect { + return nil + } + + return SpaceCreationMatrixItem(mxRoom: room) + } + } + self.itemsSubject = CurrentValueSubject(self.items) + self.filteredItems = self.items + + self.selectedItemIds = Set(selectedItemIds) + self.selectedItemIdsSubject = CurrentValueSubject(self.selectedItemIds) + } + + // MARK: - Public + + func reverseSelectionForItem(withId itemId: String) { + if selectedItemIds.contains(itemId) { + selectedItemIds.remove(itemId) + } else { + selectedItemIds.insert(itemId) + } + selectedItemIdsSubject.send(selectedItemIds) + } + +} + +fileprivate extension SpaceCreationMatrixItem { + + init(mxUser: MXUser) { + self.init(id: mxUser.userId, avatar: mxUser.avatarData, displayName: mxUser.displayname, detailText: mxUser.userId) + } + + init(mxRoom: MXRoom) { + self.init(id: mxRoom.roomId, avatar: mxRoom.avatarData, displayName: mxRoom.summary.displayname, detailText: mxRoom.summary.roomId) + } + +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Service/Mock/MockSpaceCreationMatrixItemChooserScreenState.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Service/Mock/MockSpaceCreationMatrixItemChooserScreenState.swift new file mode 100644 index 000000000..db48f6350 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Service/Mock/MockSpaceCreationMatrixItemChooserScreenState.swift @@ -0,0 +1,59 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationMatrixItemChooser SpaceCreationMatrixItemChooser +// +// 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 MockSpaceCreationMatrixItemChooserScreenState: MockScreenState, CaseIterable { + // A case for each state you want to represent + // with specific, minimal associated data that will allow you + // mock that screen. + case noItems + case items + case selectedItems + + /// The associated screen + var screenType: Any.Type { + SpaceCreationMatrixItem.self + } + + /// Generate the view struct for the screen state. + var screenView: ([Any], AnyView) { + let service: MockSpaceCreationMatrixItemChooserService + switch self { + case .noItems: + service = MockSpaceCreationMatrixItemChooserService(type: .room, items: []) + case .items: + service = MockSpaceCreationMatrixItemChooserService() + case .selectedItems: + service = MockSpaceCreationMatrixItemChooserService(type: .room, items: MockSpaceCreationMatrixItemChooserService.mockItems, selectedItemIndexes: [0, 2]) + } + let viewModel = SpaceCreationMatrixItemChooserViewModel.makeSpaceCreationMatrixItemChooserViewModel(spaceCreationMatrixItemChooserService: service, creationParams: SpaceCreationParameters()) + + // can simulate service and viewModel actions here if needs be. + + return ( + [service, viewModel], + AnyView(SpaceCreationMatrixItemChooser(viewModel: viewModel.context) + .addDependency(MockAvatarService.example)) + ) + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Service/Mock/MockSpaceCreationMatrixItemChooserService.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Service/Mock/MockSpaceCreationMatrixItemChooserService.swift new file mode 100644 index 000000000..7974f1304 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Service/Mock/MockSpaceCreationMatrixItemChooserService.swift @@ -0,0 +1,66 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationMatrixItemChooser SpaceCreationMatrixItemChooser +// +// 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 MockSpaceCreationMatrixItemChooserService: SpaceCreationMatrixItemChooserServiceProtocol { + + static let mockItems = [ + SpaceCreationMatrixItem(id: "!aaabaa:matrix.org", avatar: MockAvatarInput.example, displayName: "Matrix Discussion", detailText: "Descripton of this room"), + SpaceCreationMatrixItem(id: "!zzasds:matrix.org", avatar: MockAvatarInput.example, displayName: "Element Mobile", detailText: "Descripton of this room"), + SpaceCreationMatrixItem(id: "!scthve:matrix.org", avatar: MockAvatarInput.example, displayName: "Alice Personal", detailText: "Descripton of this room") + ] + var itemsSubject: CurrentValueSubject<[SpaceCreationMatrixItem], Never> + var selectedItemIdsSubject: CurrentValueSubject, Never> + var searchText: String = "" + var type: SpaceCreationMatrixItemType = .room + var selectedItemIds: Set = Set() + + init(type: SpaceCreationMatrixItemType = .room, items: [SpaceCreationMatrixItem] = mockItems, selectedItemIndexes: [Int] = []) { + itemsSubject = CurrentValueSubject(items) + var selectedItemIds = Set() + for index in selectedItemIndexes { + if index >= items.count { + continue + } + + selectedItemIds.insert(items[index].id) + } + selectedItemIdsSubject = CurrentValueSubject(selectedItemIds) + self.selectedItemIds = selectedItemIds + } + + func simulateSelectionForItem(at index: Int) { + guard index < itemsSubject.value.count else { + return + } + + reverseSelectionForItem(withId: itemsSubject.value[index].id) + } + + func reverseSelectionForItem(withId itemId: String) { + if selectedItemIds.contains(itemId) { + selectedItemIds.remove(itemId) + } else { + selectedItemIds.insert(itemId) + } + selectedItemIdsSubject.send(selectedItemIds) + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Service/SpaceCreationMatrixItemChooserServiceProtocol.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Service/SpaceCreationMatrixItemChooserServiceProtocol.swift new file mode 100644 index 000000000..66c533325 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Service/SpaceCreationMatrixItemChooserServiceProtocol.swift @@ -0,0 +1,30 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationMatrixItemChooser SpaceCreationMatrixItemChooser +// +// 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, *) +protocol SpaceCreationMatrixItemChooserServiceProtocol { + var type: SpaceCreationMatrixItemType { get } + var itemsSubject: CurrentValueSubject<[SpaceCreationMatrixItem], Never> { get } + var selectedItemIdsSubject: CurrentValueSubject, Never> { get } + var searchText: String { get set } + + func reverseSelectionForItem(withId itemId: String) +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Test/UI/SpaceCreationMatrixItemChooserUITests.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Test/UI/SpaceCreationMatrixItemChooserUITests.swift new file mode 100644 index 000000000..3af4d0219 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Test/UI/SpaceCreationMatrixItemChooserUITests.swift @@ -0,0 +1,70 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationMatrixItemChooser SpaceCreationMatrixItemChooser +// +// 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 SpaceCreationMatrixItemChooserUITests: MockScreenTest { + + override class var screenType: MockScreenState.Type { + return MockSpaceCreationMatrixItemChooserScreenState.self + } + + override class func createTest() -> MockScreenTest { + return SpaceCreationMatrixItemChooserUITests(selector: #selector(verifySpaceCreationMatrixItemChooserScreen)) + } + + func verifySpaceCreationMatrixItemChooserScreen() throws { + guard let screenState = screenState as? MockSpaceCreationMatrixItemChooserScreenState else { fatalError("no screen") } + switch screenState { + case .noItems: + verifyEmptyScreen() + case .items: + verifyPopulatedScreen() + case .selectedItems: + verifyPopulatedWithSelectionScreen() + } + } + + func verifyEmptyScreen() { + XCTAssertEqual(app.staticTexts["titleText"].label, VectorL10n.spacesCreationAddRoomsTitle) + XCTAssertEqual(app.staticTexts["messageText"].label, VectorL10n.spacesCreationAddRoomsMessage) + XCTAssertEqual(app.collectionViews["itemsList"].exists, false) + XCTAssertEqual(app.staticTexts["emptyListMessage"].exists, true) + XCTAssertEqual(app.staticTexts["emptyListMessage"].label, VectorL10n.spacesNoResultFoundTitle) + XCTAssertEqual(app.buttons["doneButton"].label, VectorL10n.skip) + } + + func verifyPopulatedScreen() { + XCTAssertEqual(app.staticTexts["titleText"].label, VectorL10n.spacesCreationAddRoomsTitle) + XCTAssertEqual(app.staticTexts["messageText"].label, VectorL10n.spacesCreationAddRoomsMessage) + XCTAssertEqual(app.collectionViews["itemsList"].exists, true) + XCTAssertEqual(app.staticTexts["emptyListMessage"].exists, false) + XCTAssertEqual(app.buttons["doneButton"].label, VectorL10n.skip) + } + + func verifyPopulatedWithSelectionScreen() { + XCTAssertEqual(app.staticTexts["titleText"].label, VectorL10n.spacesCreationAddRoomsTitle) + XCTAssertEqual(app.staticTexts["messageText"].label, VectorL10n.spacesCreationAddRoomsMessage) + XCTAssertEqual(app.collectionViews["itemsList"].exists, true) + XCTAssertEqual(app.staticTexts["emptyListMessage"].exists, false) + XCTAssertEqual(app.buttons["doneButton"].label, VectorL10n.next) + } + +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Test/Unit/SpaceCreationMatrixItemChooserViewModelTests.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Test/Unit/SpaceCreationMatrixItemChooserViewModelTests.swift new file mode 100644 index 000000000..483833579 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Test/Unit/SpaceCreationMatrixItemChooserViewModelTests.swift @@ -0,0 +1,53 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationMatrixItemChooser SpaceCreationMatrixItemChooser +// +// 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 SpaceCreationMatrixItemChooserViewModelTests: XCTestCase { + + var creationParameters = SpaceCreationParameters() + var service: MockSpaceCreationMatrixItemChooserService! + var viewModel: SpaceCreationMatrixItemChooserViewModelProtocol! + var context: SpaceCreationMatrixItemChooserViewModel.Context! + + override func setUpWithError() throws { + service = MockSpaceCreationMatrixItemChooserService(type: .room) + viewModel = SpaceCreationMatrixItemChooserViewModel.makeSpaceCreationMatrixItemChooserViewModel(spaceCreationMatrixItemChooserService: service, creationParams: creationParameters) + context = viewModel.context + } + + func testInitialState() { + XCTAssertEqual(context.viewState.navTitle, creationParameters.isPublic ? VectorL10n.spacesCreationPublicSpaceTitle : VectorL10n.spacesCreationPrivateSpaceTitle) + XCTAssertEqual(context.viewState.emptyListMessage, VectorL10n.spacesNoResultFoundTitle) + XCTAssertEqual(context.viewState.title, VectorL10n.spacesCreationAddRoomsTitle) + XCTAssertEqual(context.viewState.message, VectorL10n.spacesCreationAddRoomsMessage) + XCTAssertEqual(context.viewState.items, MockSpaceCreationMatrixItemChooserService.mockItems) + XCTAssertEqual(context.viewState.selectedItemIds.count, 0) + } + + func testItemSelection() throws { + XCTAssertEqual(context.viewState.selectedItemIds.count, 0) + service.simulateSelectionForItem(at: 0) + XCTAssertEqual(context.viewState.selectedItemIds.count, 1) + XCTAssertEqual(context.viewState.selectedItemIds.first, MockSpaceCreationMatrixItemChooserService.mockItems[0].id) + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/View/SpaceCreationMatrixItemChooser.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/View/SpaceCreationMatrixItemChooser.swift new file mode 100644 index 000000000..a31cf7a1a --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/View/SpaceCreationMatrixItemChooser.swift @@ -0,0 +1,136 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationMatrixItemChooser SpaceCreationMatrixItemChooser +// +// 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 SpaceCreationMatrixItemChooser: View { + + // MARK: - Properties + + @ObservedObject var viewModel: SpaceCreationMatrixItemChooserViewModel.Context + @State var searchText: String = "" + + // MARK: Private + + @Environment(\.theme) private var theme: ThemeSwiftUI + + // MARK: Public + + @ViewBuilder + var body: some View { + VStack { + headerView + listContent + footerView + } + .background(theme.colors.background) + .navigationTitle(viewModel.viewState.navTitle) + .configureNavigationBar{ + $0.navigationBar.shadowImage = UIImage() + $0.navigationBar.barTintColor = UIColor(theme.colors.background) + $0.navigationBar.tintColor = UIColor(theme.colors.secondaryContent) + } + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button(action: { + viewModel.send(viewAction: .cancel) + }) { + Image(uiImage: Asset.Images.spacesModalClose.image).renderingMode(.template) + } + } + } + } + + @ViewBuilder + var headerView: some View { + VStack { + Text(viewModel.viewState.title) + .font(theme.fonts.bodySB) + .foregroundColor(theme.colors.primaryContent) + .padding(.horizontal) + .padding(.vertical, 8) + .accessibility(identifier: "titleText") + Text(viewModel.viewState.message) + .font(theme.fonts.callout) + .foregroundColor(theme.colors.secondaryContent) + .multilineTextAlignment(.center) + .padding(.horizontal) + .accessibility(identifier: "messageText") + Spacer().frame(height: 24) + SearchBar(placeholder: VectorL10n.searchableDirectorySearchPlaceholder, text: $searchText) + .onChange(of: searchText, perform: { value in + viewModel.send(viewAction: .searchTextChanged(searchText)) + }) + } + } + + @ViewBuilder + var listContent: some View { + ScrollView{ + if viewModel.viewState.items.isEmpty { + Text(viewModel.viewState.emptyListMessage) + .font(theme.fonts.body) + .foregroundColor(theme.colors.secondaryContent) + .accessibility(identifier: "emptyListMessage") + Spacer() + } else { + LazyVStack(spacing: 0) { + ForEach(viewModel.viewState.items) { item in + Button { + viewModel.send(viewAction: .itemTapped(item.id)) + } label: { + SpaceCreationMatrixItemChooserListRow( + avatar: item.avatar, + displayName: item.displayName, + detailText: item.detailText, + isSelected: viewModel.viewState.selectedItemIds.contains(item.id) + ) + } + } + } + .accessibility(identifier: "itemsList") + .frame(maxHeight: .infinity, alignment: .top) + } + } + } + + @ViewBuilder + var footerView: some View { + ThemableButton(icon: nil, title: viewModel.viewState.selectedItemIds.isEmpty ? VectorL10n.skip : VectorL10n.next) { + viewModel.send(viewAction: .done) + } + .accessibility(identifier: "doneButton") + .padding(.horizontal, 24) + .padding(.bottom, 8) + } +} + +// MARK: - Previews + +@available(iOS 14.0, *) +struct SpaceCreationMatrixItemChooser_Previews: PreviewProvider { + + static let stateRenderer = MockSpaceCreationMatrixItemChooserScreenState.stateRenderer + static var previews: some View { + stateRenderer.screenGroup(addNavigation: true) + .theme(.light).preferredColorScheme(.light) + stateRenderer.screenGroup(addNavigation: true) + .theme(.dark).preferredColorScheme(.dark) + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/View/SpaceCreationMatrixItemChooserListRow.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/View/SpaceCreationMatrixItemChooserListRow.swift new file mode 100644 index 000000000..9d70139e6 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/View/SpaceCreationMatrixItemChooserListRow.swift @@ -0,0 +1,73 @@ +// +// 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 SpaceCreationMatrixItemChooserListRow: View { + + // MARK: - Properties + + // MARK: Private + + @Environment(\.theme) private var theme: ThemeSwiftUI + + // MARK: Public + + let avatar: AvatarInputProtocol + let displayName: String? + let detailText: String? + let isSelected: Bool + + @ViewBuilder + var body: some View { + HStack{ + AvatarImage(avatarData: avatar, size: .small) + VStack(alignment: .leading) { + Text(displayName ?? "") + .foregroundColor(theme.colors.primaryContent) + .font(theme.fonts.callout) + .accessibility(identifier: "itemNameText") + if let detailText = self.detailText { + Text(detailText) + .foregroundColor(theme.colors.secondaryContent) + .font(theme.fonts.footnote) + .accessibility(identifier: "itemDetailText") + } + } + Spacer() + if isSelected { + Image(systemName: "checkmark.circle.fill").renderingMode(.template).foregroundColor(theme.colors.accent) + } else { + Image(systemName: "circle").renderingMode(.template).foregroundColor(theme.colors.tertiaryContent) + } + } + //add to a style + .padding(.horizontal) + .padding(.vertical, 12) + .frame(maxWidth: .infinity) + } +} + +// MARK: - Previews + +@available(iOS 14.0, *) +struct SpaceCreationMatrixItemChooserListRow_Previews: PreviewProvider { + static var previews: some View { + TemplateRoomListRow(avatar: MockAvatarInput.example, displayName: "Alice") + .addDependency(MockAvatarService.example) + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/ViewModel/SpaceCreationMatrixItemChooserViewModel.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/ViewModel/SpaceCreationMatrixItemChooserViewModel.swift new file mode 100644 index 000000000..aad0deba7 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/ViewModel/SpaceCreationMatrixItemChooserViewModel.swift @@ -0,0 +1,114 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationMatrixItemChooser SpaceCreationMatrixItemChooser +// +// 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 SpaceCreationMatrixItemChooserViewModelType = StateStoreViewModel +@available(iOS 14, *) +class SpaceCreationMatrixItemChooserViewModel: SpaceCreationMatrixItemChooserViewModelType, SpaceCreationMatrixItemChooserViewModelProtocol { + + // MARK: - Properties + + // MARK: Private + + private var spaceCreationMatrixItemChooserService: SpaceCreationMatrixItemChooserServiceProtocol + private var creationParams: SpaceCreationParameters + + // MARK: Public + + var callback: ((SpaceCreationMatrixItemListStateActionListViewModelAction) -> Void)? + + // MARK: - Setup + + static func makeSpaceCreationMatrixItemChooserViewModel(spaceCreationMatrixItemChooserService: SpaceCreationMatrixItemChooserServiceProtocol, creationParams: SpaceCreationParameters) -> SpaceCreationMatrixItemChooserViewModelProtocol { + return SpaceCreationMatrixItemChooserViewModel(spaceCreationMatrixItemChooserService: spaceCreationMatrixItemChooserService, creationParams: creationParams) + } + + private init(spaceCreationMatrixItemChooserService: SpaceCreationMatrixItemChooserServiceProtocol, creationParams: SpaceCreationParameters) { + self.spaceCreationMatrixItemChooserService = spaceCreationMatrixItemChooserService + self.creationParams = creationParams + super.init(initialViewState: Self.defaultState(spaceCreationMatrixItemChooserService: spaceCreationMatrixItemChooserService, creationParams: creationParams)) + startObservingItems() + } + + private static func defaultState(spaceCreationMatrixItemChooserService: SpaceCreationMatrixItemChooserServiceProtocol, creationParams: SpaceCreationParameters) -> SpaceCreationMatrixItemListStateActionListViewState { + let navTitle = creationParams.isPublic ? VectorL10n.spacesCreationPublicSpaceTitle : VectorL10n.spacesCreationPrivateSpaceTitle + let title = spaceCreationMatrixItemChooserService.type == .people ? VectorL10n.spacesCreationInviteByUsernameTitle : VectorL10n.spacesCreationAddRoomsTitle + let message = spaceCreationMatrixItemChooserService.type == .people ? VectorL10n.spacesCreationInviteByUsernameMessage : VectorL10n.spacesCreationAddRoomsMessage + let emptyListMessage = VectorL10n.spacesNoResultFoundTitle + + return SpaceCreationMatrixItemListStateActionListViewState(navTitle: navTitle, title: title, message: message, emptyListMessage: emptyListMessage, items: spaceCreationMatrixItemChooserService.itemsSubject.value, selectedItemIds: spaceCreationMatrixItemChooserService.selectedItemIdsSubject.value) + } + + private func startObservingItems() { + let itemsUpdatePublisher = spaceCreationMatrixItemChooserService.itemsSubject + .map(SpaceCreationMatrixItemListStateAction.updateItems) + .eraseToAnyPublisher() + dispatch(actionPublisher: itemsUpdatePublisher) + + let selectionPublisher = spaceCreationMatrixItemChooserService.selectedItemIdsSubject + .map(SpaceCreationMatrixItemListStateAction.updateSelection) + .eraseToAnyPublisher() + dispatch(actionPublisher: selectionPublisher) + } + + // MARK: - Public + + override func process(viewAction: SpaceCreationMatrixItemListStateActionListViewAction) { + switch viewAction { + case .cancel: + cancel() + case .done: + let selectedItemIds = Array(spaceCreationMatrixItemChooserService.selectedItemIdsSubject.value) + switch spaceCreationMatrixItemChooserService.type { + case .people: + creationParams.userIdInvites = selectedItemIds + default: + creationParams.addedRoomIds = selectedItemIds + } + done() + case .searchTextChanged(let searchText): + self.spaceCreationMatrixItemChooserService.searchText = searchText + case .itemTapped(let itemId): + self.spaceCreationMatrixItemChooserService.reverseSelectionForItem(withId: itemId) + } + } + + override class func reducer(state: inout SpaceCreationMatrixItemListStateActionListViewState, action: SpaceCreationMatrixItemListStateAction) { + switch action { + case .updateItems(let items): + state.items = items + case .updateSelection(let selectedItemIds): + state.selectedItemIds = selectedItemIds + } + UILog.debug("[SpaceCreationMatrixItemChooserViewModel] reducer with action \(action) produced state: \(state)") + } + + private func done() { + callback?(.done) + } + + private func cancel() { + callback?(.cancel) + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/ViewModel/SpaceCreationMatrixItemChooserViewModelProtocol.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/ViewModel/SpaceCreationMatrixItemChooserViewModelProtocol.swift new file mode 100644 index 000000000..3009daf32 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/ViewModel/SpaceCreationMatrixItemChooserViewModelProtocol.swift @@ -0,0 +1,28 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationMatrixItemChooser SpaceCreationMatrixItemChooser +// +// 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 SpaceCreationMatrixItemChooserViewModelProtocol { + + var callback: ((SpaceCreationMatrixItemListStateActionListViewModelAction) -> Void)? { get set } + @available(iOS 14, *) + static func makeSpaceCreationMatrixItemChooserViewModel(spaceCreationMatrixItemChooserService: SpaceCreationMatrixItemChooserServiceProtocol, creationParams: SpaceCreationParameters) -> SpaceCreationMatrixItemChooserViewModelProtocol + @available(iOS 14, *) + var context: SpaceCreationMatrixItemChooserViewModelType.Context { get } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Coordinator/SpaceCreationMenuCoordinator.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Coordinator/SpaceCreationMenuCoordinator.swift new file mode 100644 index 000000000..ec7c54aaa --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Coordinator/SpaceCreationMenuCoordinator.swift @@ -0,0 +1,73 @@ +// File created from TemplateAdvancedRoomsExample +// $ createSwiftUITwoScreen.sh Spaces/SpaceCreation SpaceCreation SpaceCreationMenu SpaceCreationSettings +/* + 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 UIKit +import SwiftUI + +final class SpaceCreationMenuCoordinator: Coordinator, Presentable { + + // MARK: - Properties + + // MARK: Private + + private let parameters: SpaceCreationMenuCoordinatorParameters + private let spaceCreationMenuHostingController: UIViewController + private var spaceCreationMenuViewModel: SpaceCreationMenuViewModelProtocol + + // MARK: Public + + // Must be used only internally + var childCoordinators: [Coordinator] = [] + var callback: ((SpaceCreationMenuCoordinatorAction) -> Void)? + + // MARK: - Setup + + @available(iOS 14.0, *) + init(parameters: SpaceCreationMenuCoordinatorParameters) { + self.parameters = parameters + let viewModel = SpaceCreationMenuViewModel(navTitle: parameters.navTitle, creationParams: parameters.creationParams, title: parameters.title, detail: parameters.detail, options: parameters.options) + let view = SpaceCreationMenu(viewModel: viewModel.context) + .addDependency(AvatarService.instantiate(mediaManager: parameters.session.mediaManager)) + spaceCreationMenuViewModel = viewModel + let hostingController = VectorHostingController(rootView: view) + hostingController.hidesBackTitleWhenPushed = true + spaceCreationMenuHostingController = hostingController + } + + // MARK: - Public + + func start() { + MXLog.debug("[SpaceCreationMenuCoordinator] did start.") + spaceCreationMenuViewModel.callback = { [weak self] result in + MXLog.debug("[SpaceCreationMenuCoordinator] SpaceCreationMenuViewModel did complete with result \(result).") + guard let self = self else { return } + switch result { + case .didSelectOption(let optionId): + self.callback?(.didSelectOption(optionId)) + case .cancel: + self.callback?(.cancel) + break + } + } + } + + func toPresentable() -> UIViewController { + return self.spaceCreationMenuHostingController + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Coordinator/SpaceCreationMenuCoordinatorParamaters.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Coordinator/SpaceCreationMenuCoordinatorParamaters.swift new file mode 100644 index 000000000..bd0222d06 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Coordinator/SpaceCreationMenuCoordinatorParamaters.swift @@ -0,0 +1,28 @@ +// File created from TemplateAdvancedRoomsExample +// $ createSwiftUITwoScreen.sh Spaces/SpaceCreation SpaceCreation SpaceCreationMenu SpaceCreationSettings +// +// 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 + +struct SpaceCreationMenuCoordinatorParameters { + let session: MXSession + let creationParams: SpaceCreationParameters + let navTitle: String? + let title: String + let detail: String + let options: [SpaceCreationMenuRoomOption] +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Model/SpaceCreationMenuCoordinatorAction.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Model/SpaceCreationMenuCoordinatorAction.swift new file mode 100644 index 000000000..5a416809a --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Model/SpaceCreationMenuCoordinatorAction.swift @@ -0,0 +1,25 @@ +// File created from TemplateAdvancedRoomsExample +// $ createSwiftUITwoScreen.sh Spaces/SpaceCreation SpaceCreation SpaceCreationMenu SpaceCreationSettings +// +// 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 + +/// Actions returned by the coordinator callback +enum SpaceCreationMenuCoordinatorAction { + case didSelectOption(_ optionId: SpaceCreationMenuRoomOptionId) + case cancel +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Model/SpaceCreationMenuRoom.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Model/SpaceCreationMenuRoom.swift new file mode 100644 index 000000000..f0fef99a6 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Model/SpaceCreationMenuRoom.swift @@ -0,0 +1,36 @@ +// File created from TemplateAdvancedRoomsExample +// $ createSwiftUITwoScreen.sh Spaces/SpaceCreation SpaceCreation SpaceCreationMenu SpaceCreationSettings +// +// 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 UIKit + +enum SpaceCreationMenuRoomOptionId { + case publicSpace + case privateSpace + case ownedPrivateSpace + case sharedPrivateSpace +} + +struct SpaceCreationMenuRoomOption { + let id: SpaceCreationMenuRoomOptionId + let icon: UIImage + let title: String + let detail: String +} + +extension SpaceCreationMenuRoomOption: Identifiable, Equatable {} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Model/SpaceCreationMenuStateAction.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Model/SpaceCreationMenuStateAction.swift new file mode 100644 index 000000000..3109b348b --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Model/SpaceCreationMenuStateAction.swift @@ -0,0 +1,23 @@ +// File created from TemplateAdvancedRoomsExample +// $ createSwiftUITwoScreen.sh Spaces/SpaceCreation SpaceCreation SpaceCreationMenu SpaceCreationSettings +// +// 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 + +/// Actions to be performed on the `ViewModel` State +enum SpaceCreationMenuStateAction { +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Model/SpaceCreationMenuViewAction.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Model/SpaceCreationMenuViewAction.swift new file mode 100644 index 000000000..8cbf21e2c --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Model/SpaceCreationMenuViewAction.swift @@ -0,0 +1,25 @@ +// File created from TemplateAdvancedRoomsExample +// $ createSwiftUITwoScreen.sh Spaces/SpaceCreation SpaceCreation SpaceCreationMenu SpaceCreationSettings +// +// 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 + +/// Actions send from the `View` to the `ViewModel`. +enum SpaceCreationMenuViewAction { + case cancel + case didSelectOption(_ optionId: SpaceCreationMenuRoomOptionId) +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Model/SpaceCreationMenuViewModelAction.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Model/SpaceCreationMenuViewModelAction.swift new file mode 100644 index 000000000..2df5ee7c0 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Model/SpaceCreationMenuViewModelAction.swift @@ -0,0 +1,25 @@ +// File created from TemplateAdvancedRoomsExample +// $ createSwiftUITwoScreen.sh Spaces/SpaceCreation SpaceCreation SpaceCreationMenu SpaceCreationSettings +// +// 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 + +/// Actions sent by the`ViewModel` to the `Coordinator`. +enum SpaceCreationMenuViewModelAction { + case didSelectOption(_ optionId: SpaceCreationMenuRoomOptionId) + case cancel +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Model/SpaceCreationMenuViewState.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Model/SpaceCreationMenuViewState.swift new file mode 100644 index 000000000..d25f2bf1a --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Model/SpaceCreationMenuViewState.swift @@ -0,0 +1,27 @@ +// File created from TemplateAdvancedRoomsExample +// $ createSwiftUITwoScreen.sh Spaces/SpaceCreation SpaceCreation SpaceCreationMenu SpaceCreationSettings +// +// 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 + +/// State managed by the `ViewModel` delivered to the `View`. +struct SpaceCreationMenuViewState: BindableState { + var navTitle: String + var title: String + var detail: String + var options: [SpaceCreationMenuRoomOption] +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Model/SpaceCreationParameters.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Model/SpaceCreationParameters.swift new file mode 100644 index 000000000..48686179c --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Model/SpaceCreationParameters.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 UIKit + +class SpaceCreationParameters { + var name: String? + var topic: String? + var address: String? + var userDefinedAddress: String? + var isPublic: Bool = false + var showAddress: Bool { + isPublic + } + + var userSelectedAvatar: UIImage? + var isShared: Bool = false + + var newRooms: [SpaceCreationNewRoom] = [ + SpaceCreationNewRoom(name: VectorL10n.spacesCreationNewRoomsGeneral, defaultName: VectorL10n.spacesCreationNewRoomsGeneral), + SpaceCreationNewRoom(name: VectorL10n.spacesCreationNewRoomsRandom, defaultName: VectorL10n.spacesCreationNewRoomsRandom), + SpaceCreationNewRoom(name: "", defaultName: VectorL10n.spacesCreationNewRoomsSupport) + ] + var addedRoomIds: [String] = [] + + var emailInvites: [String] = ["", ""] + var userDefinedEmailInvites: [String] { + return emailInvites.filter { address in + return !address.isEmpty + } + } + var userIdInvites: [String] = [] +} + +struct SpaceCreationNewRoom: Equatable { + var name: String + var defaultName: String + + static func == (lhs: Self, rhs: Self) -> Bool { + return lhs.defaultName == rhs.defaultName && lhs.name == rhs.name + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Test/UI/SpaceCreationMenuUITests.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Test/UI/SpaceCreationMenuUITests.swift new file mode 100644 index 000000000..068d36c55 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Test/UI/SpaceCreationMenuUITests.swift @@ -0,0 +1,54 @@ +// File created from TemplateAdvancedRoomsExample +// $ createSwiftUITwoScreen.sh Spaces/SpaceCreation SpaceCreation SpaceCreationMenu SpaceCreationSettings +// +// 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 SpaceCreationMenuUITests: MockScreenTest { + + override class var screenType: MockScreenState.Type { + return MockSpaceCreationMenuScreenState.self + } + + override class func createTest() -> MockScreenTest { + return SpaceCreationMenuUITests(selector: #selector(verifySpaceCreationMenuScreen)) + } + + func verifySpaceCreationMenuScreen() throws { + guard let screenState = screenState as? MockSpaceCreationMenuScreenState else { fatalError("no screen") } + switch screenState { + case .options: + verifySpaceCreationMenuOptions() + } + } + + func verifySpaceCreationMenuOptions() { + let optionButtonCount = app.buttons.matching(identifier:"optionButton").count + XCTAssertEqual(optionButtonCount, 2) + + let titleText = app.staticTexts["titleText"] + XCTAssert(titleText.exists) + XCTAssert(titleText.label == "Some title") + + let detailText = app.staticTexts["detailText"] + XCTAssert(detailText.exists) + XCTAssert(detailText.label == "Some title") + } + +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Test/Unit/SpaceCreationMenuViewModelTests.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Test/Unit/SpaceCreationMenuViewModelTests.swift new file mode 100644 index 000000000..50f4b099b --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Test/Unit/SpaceCreationMenuViewModelTests.swift @@ -0,0 +1,59 @@ +// File created from TemplateAdvancedRoomsExample +// $ createSwiftUITwoScreen.sh Spaces/SpaceCreation SpaceCreation SpaceCreationMenu SpaceCreationSettings +// +// 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 SpaceCreationMenuViewModelTests: XCTestCase { + private enum Constants { + } + + let navTitle = VectorL10n.spacesCreateSpaceTitle + var creationParams = SpaceCreationParameters() + let title = VectorL10n.spacesCreateSpaceTitle + let detail = VectorL10n.spacesCreationVisibilityMessage + let options = [ + SpaceCreationMenuRoomOption(id: .publicSpace, icon: Asset.Images.spaceTypeIcon.image, title: VectorL10n.spacePublicJoinRule, detail: VectorL10n.spacePublicJoinRuleDetail), + SpaceCreationMenuRoomOption(id: .privateSpace, icon: Asset.Images.spacePrivateIcon.image, title: VectorL10n.spacePrivateJoinRule, detail: VectorL10n.spacePrivateJoinRuleDetail) + ] + + var viewModel: SpaceCreationMenuViewModel! + var context: SpaceCreationMenuViewModel.Context! + var cancellables = Set() + + override func setUpWithError() throws { + viewModel = SpaceCreationMenuViewModel( + navTitle: navTitle, + creationParams: creationParams, + title: title, + detail: detail, + options: options + ) + context = viewModel.context + } + + func testInitialState() { + XCTAssertEqual(context.viewState.navTitle, navTitle) + XCTAssertEqual(context.viewState.title, title) + XCTAssertEqual(context.viewState.detail, detail) + XCTAssertEqual(context.viewState.options, options) + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/View/SpaceCreationMenu.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/View/SpaceCreationMenu.swift new file mode 100644 index 000000000..d96440bfa --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/View/SpaceCreationMenu.swift @@ -0,0 +1,150 @@ +// File created from TemplateAdvancedRoomsExample +// $ createSwiftUITwoScreen.sh Spaces/SpaceCreation SpaceCreation SpaceCreationMenu SpaceCreationSettings +// +// 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 SpaceCreationMenu: View { + + // MARK: - Properties + + @ObservedObject var viewModel: SpaceCreationMenuViewModelType.Context + + // MARK: Private + + @Environment(\.theme) private var theme: ThemeSwiftUI + + // MARK: Public + + var body: some View { + mainScreen + .navigationTitle(viewModel.viewState.navTitle) + .configureNavigationBar{ + $0.navigationBar.shadowImage = UIImage() + $0.navigationBar.barTintColor = UIColor(theme.colors.background) + $0.navigationBar.tintColor = UIColor(theme.colors.secondaryContent) + } + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button(action: { + viewModel.send(viewAction: .cancel) + }) { + Image(uiImage: Asset.Images.spacesModalClose.image).renderingMode(.template) + } + } + } + } + + // MARK: - Private + + @ViewBuilder + private var mainScreen: some View { + GeometryReader { reader in + ScrollView { + VStack { + headerView + Spacer() + optionsView + } + .frame(minHeight: reader.size.height - 2) + } + } + .padding(EdgeInsets(top: 24, leading: 16, bottom: 24, trailing: 16)) + .background(theme.colors.background) + } + + @ViewBuilder + private var headerView: some View { + VStack { + Text(viewModel.viewState.title) + .multilineTextAlignment(.center) + .font(theme.fonts.title3SB) + .foregroundColor(theme.colors.primaryContent) + .accessibility(identifier: "titleText") + .padding(.bottom, 20) + Text(viewModel.viewState.detail) + .multilineTextAlignment(.center) + .font(theme.fonts.body) + .foregroundColor(theme.colors.secondaryContent) + .accessibility(identifier: "detailText") + } + } + + @ViewBuilder + private var optionsView: some View { + VStack(spacing: 16) { + ForEach(viewModel.viewState.options) { option in + OptionButton(icon: option.icon, title: option.title, detailMessage: option.detail) { + viewModel.send(viewAction: .didSelectOption(option.id)) + } + .accessibility(identifier: "optionButton") + } + Text(VectorL10n.spacesCreationFooter) + .multilineTextAlignment(.center) + .font(theme.fonts.caption1) + .foregroundColor(theme.colors.secondaryContent) + } + } +} + +// MARK: - Previews + +@available(iOS 14.0, *) +struct SpaceCreationMenu_Previews: PreviewProvider { + + static let stateRenderer = MockSpaceCreationMenuScreenState.stateRenderer + + static var previews: some View { + Group { + stateRenderer.screenGroup(addNavigation: true) + .theme(.light).preferredColorScheme(.light) + stateRenderer.screenGroup(addNavigation: true) + .theme(.dark).preferredColorScheme(.dark) + } + } +} + +/// 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 MockSpaceCreationMenuScreenState: MockScreenState, CaseIterable { + // A case for each state you want to represent + // with specific, minimal associated data that will allow you + // mock that screen. + case options + + /// The associated screen + var screenType: Any.Type { + SpaceCreationMenu.self + } + + /// Generate the view struct for the screen state. + var screenView: ([Any], AnyView) { + let viewModel = SpaceCreationMenuViewModel(navTitle: VectorL10n.spacesCreateSpaceTitle, creationParams: SpaceCreationParameters(), title: "Some title", detail: "Some detail text", options: [ + SpaceCreationMenuRoomOption(id: .publicSpace, icon: Asset.Images.spaceTypeIcon.image, title: "Title of option 1", detail: "Detail of option 1"), + SpaceCreationMenuRoomOption(id: .publicSpace, icon: Asset.Images.spaceTypeIcon.image, title: "Title of option 2", detail: "Detail of option 2") + ]) + + // can simulate service and viewModel actions here if needs be. + + return ( + [viewModel], + AnyView(SpaceCreationMenu(viewModel: viewModel.context)) + ) + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/ViewModel/SpaceCreationMenuViewModel.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/ViewModel/SpaceCreationMenuViewModel.swift new file mode 100644 index 000000000..10504eb08 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/ViewModel/SpaceCreationMenuViewModel.swift @@ -0,0 +1,89 @@ +// File created from TemplateAdvancedRoomsExample +// $ createSwiftUITwoScreen.sh Spaces/SpaceCreation SpaceCreation SpaceCreationMenu SpaceCreationSettings +// +// 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 SpaceCreationMenuViewModelType = StateStoreViewModel +@available(iOS 14.0, *) +class SpaceCreationMenuViewModel: SpaceCreationMenuViewModelType, SpaceCreationMenuViewModelProtocol { + + // MARK: - Properties + + // MARK: Private + + let creationParams: SpaceCreationParameters + + // MARK: Public + + var callback: ((SpaceCreationMenuViewModelAction) -> Void)? + + // MARK: - Setup + + init(navTitle: String?, creationParams: SpaceCreationParameters, title: String, detail: String, options: [SpaceCreationMenuRoomOption]) { + self.creationParams = creationParams + + super.init(initialViewState: SpaceCreationMenuViewModel.defaultState(navTitle: navTitle, creationParams: creationParams, title: title, detail: detail, options: options)) + } + + private static func defaultState(navTitle: String?, creationParams: SpaceCreationParameters, title: String, detail: String, options: [SpaceCreationMenuRoomOption]) -> SpaceCreationMenuViewState { + var navigationTitle: String = "" + if let navTitle = navTitle { + navigationTitle = navTitle + } else { + navigationTitle = creationParams.isPublic ? VectorL10n.spacesCreationPublicSpaceTitle : VectorL10n.spacesCreationPrivateSpaceTitle + } + + return SpaceCreationMenuViewState(navTitle: navigationTitle, title: title, detail: detail, options: options) + } + + // MARK: - Public + + override func process(viewAction: SpaceCreationMenuViewAction) { + switch viewAction { + case .didSelectOption(let optionId): + switch optionId { + case .publicSpace: + self.creationParams.isPublic = true + case .privateSpace: + self.creationParams.isPublic = false + case .ownedPrivateSpace: + self.creationParams.isShared = false + case .sharedPrivateSpace: + self.creationParams.isShared = true + } + + didSelectOption(withId: optionId) + case .cancel: + done() + } + } + + // MARK: - Private + + private func done() { + callback?(.cancel) + } + + private func didSelectOption(withId optionId: SpaceCreationMenuRoomOptionId) { + callback?(.didSelectOption(optionId)) + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/ViewModel/SpaceCreationMenuViewModelProtocol.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/ViewModel/SpaceCreationMenuViewModelProtocol.swift new file mode 100644 index 000000000..4012e8d57 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/ViewModel/SpaceCreationMenuViewModelProtocol.swift @@ -0,0 +1,25 @@ +// File created from TemplateAdvancedRoomsExample +// $ createSwiftUITwoScreen.sh Spaces/SpaceCreation SpaceCreation SpaceCreationMenu SpaceCreationSettings +// +// 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 SpaceCreationMenuViewModelProtocol { + var callback: ((SpaceCreationMenuViewModelAction) -> Void)? { get set } + @available(iOS 14, *) + var context: SpaceCreationMenuViewModelType.Context { get } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Coordinator/SpaceCreationPostProcessCoordinator.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Coordinator/SpaceCreationPostProcessCoordinator.swift new file mode 100644 index 000000000..52136fc1c --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Coordinator/SpaceCreationPostProcessCoordinator.swift @@ -0,0 +1,71 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationPostProcess SpaceCreationPostProcess +/* + 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 UIKit +import SwiftUI + +final class SpaceCreationPostProcessCoordinator: Coordinator, Presentable { + + // MARK: - Properties + + // MARK: Private + + private let parameters: SpaceCreationPostProcessCoordinatorParameters + private let spaceCreationPostProcessHostingController: UIViewController + private var spaceCreationPostProcessViewModel: SpaceCreationPostProcessViewModelProtocol + + // MARK: Public + + // Must be used only internally + var childCoordinators: [Coordinator] = [] + var callback: ((SpaceCreationPostProcessCoordinatorAction) -> Void)? + + // MARK: - Setup + + @available(iOS 14.0, *) + init(parameters: SpaceCreationPostProcessCoordinatorParameters) { + self.parameters = parameters + let viewModel = SpaceCreationPostProcessViewModel.makeSpaceCreationPostProcessViewModel(spaceCreationPostProcessService: SpaceCreationPostProcessService(session: parameters.session, creationParams: parameters.creationParams)) + let view = SpaceCreationPostProcess(viewModel: viewModel.context) + .addDependency(AvatarService.instantiate(mediaManager: parameters.session.mediaManager)) + spaceCreationPostProcessViewModel = viewModel + let hostingController = VectorHostingController(rootView: view) + hostingController.hidesBackTitleWhenPushed = true + spaceCreationPostProcessHostingController = hostingController + } + + // MARK: - Public + func start() { + MXLog.debug("[SpaceCreationPostProcessCoordinator] did start.") + spaceCreationPostProcessViewModel.completion = { [weak self] result in + MXLog.debug("[SpaceCreationPostProcessCoordinator] SpaceCreationPostProcessViewModel did complete with result: \(result).") + guard let self = self else { return } + switch result { + case .cancel: + self.callback?(.cancel) + case .done(let spaceId): + self.callback?(.done(spaceId)) + } + } + } + + func toPresentable() -> UIViewController { + return self.spaceCreationPostProcessHostingController + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Coordinator/SpaceCreationPostProcessCoordinatorParameters.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Coordinator/SpaceCreationPostProcessCoordinatorParameters.swift new file mode 100644 index 000000000..be2857b70 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Coordinator/SpaceCreationPostProcessCoordinatorParameters.swift @@ -0,0 +1,24 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationPostProcess SpaceCreationPostProcess +// +// 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 + +struct SpaceCreationPostProcessCoordinatorParameters { + let session: MXSession + let creationParams: SpaceCreationParameters +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Model/SpaceCreationPostProcessCoordinatorAction.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Model/SpaceCreationPostProcessCoordinatorAction.swift new file mode 100644 index 000000000..d384c3a5d --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Model/SpaceCreationPostProcessCoordinatorAction.swift @@ -0,0 +1,22 @@ +// +// 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 + +enum SpaceCreationPostProcessCoordinatorAction { + case done(_ spaceId: String) + case cancel +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Model/SpaceCreationPostProcessPresence.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Model/SpaceCreationPostProcessPresence.swift new file mode 100644 index 000000000..7b872e286 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Model/SpaceCreationPostProcessPresence.swift @@ -0,0 +1,44 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationPostProcess SpaceCreationPostProcess +// +// 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 + +enum SpaceCreationPostProcessPresence { + case online + case idle + case offline +} + +extension SpaceCreationPostProcessPresence { + var title: String { + switch self { + case .online: + return VectorL10n.roomParticipantsOnline + case .idle: + return VectorL10n.roomParticipantsIdle + case .offline: + return VectorL10n.roomParticipantsOffline + } + } +} + +extension SpaceCreationPostProcessPresence: CaseIterable { } + +extension SpaceCreationPostProcessPresence: Identifiable { + var id: Self { self } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Model/SpaceCreationPostProcessStateAction.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Model/SpaceCreationPostProcessStateAction.swift new file mode 100644 index 000000000..31003ed4e --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Model/SpaceCreationPostProcessStateAction.swift @@ -0,0 +1,23 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationPostProcess SpaceCreationPostProcess +// +// 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 + +enum SpaceCreationPostProcessStateAction { + case updateTasks([SpaceCreationPostProcessTask]) +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Model/SpaceCreationPostProcessTask.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Model/SpaceCreationPostProcessTask.swift new file mode 100644 index 000000000..20763af41 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Model/SpaceCreationPostProcessTask.swift @@ -0,0 +1,44 @@ +// +// 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 + +enum SpaceCreationPostProcessTaskState: CaseIterable { + static var allCases: [SpaceCreationPostProcessTaskState] = [.none, .started, .success, .failure] + + case none + case started + case success + case failure +} + +enum SpaceCreationPostProcessTaskType { + case createSpace + case uploadAvatar + case createRoom(_ roomName: String) + case addRooms + case inviteUsersByEmail +} + +struct SpaceCreationPostProcessTask { + let type: SpaceCreationPostProcessTaskType + let title: String + var state: SpaceCreationPostProcessTaskState + var isFinished: Bool { + return state == .failure || state == .success + } + var subTasks: [SpaceCreationPostProcessTask] = [] +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Model/SpaceCreationPostProcessViewAction.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Model/SpaceCreationPostProcessViewAction.swift new file mode 100644 index 000000000..dc7397f46 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Model/SpaceCreationPostProcessViewAction.swift @@ -0,0 +1,25 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationPostProcess SpaceCreationPostProcess +// +// 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 + +enum SpaceCreationPostProcessViewAction { + case cancel + case runTasks + case retry +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Model/SpaceCreationPostProcessViewModelResult.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Model/SpaceCreationPostProcessViewModelResult.swift new file mode 100644 index 000000000..38ee7179d --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Model/SpaceCreationPostProcessViewModelResult.swift @@ -0,0 +1,24 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationPostProcess SpaceCreationPostProcess +// +// 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 + +enum SpaceCreationPostProcessViewModelResult { + case cancel + case done(_ spaceId: String) +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Model/SpaceCreationPostProcessViewState.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Model/SpaceCreationPostProcessViewState.swift new file mode 100644 index 000000000..95c5e95cd --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Model/SpaceCreationPostProcessViewState.swift @@ -0,0 +1,25 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationPostProcess SpaceCreationPostProcess +// +// 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 + +struct SpaceCreationPostProcessViewState: BindableState { + var tasks: [SpaceCreationPostProcessTask] + var isFinished: Bool + var errorCount: Int +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Service/MatrixSDK/SpaceCreationPostProcessService.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Service/MatrixSDK/SpaceCreationPostProcessService.swift new file mode 100644 index 000000000..d2f0ae8f2 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Service/MatrixSDK/SpaceCreationPostProcessService.swift @@ -0,0 +1,330 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationPostProcess SpaceCreationPostProcess +// +// 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 SpaceCreationPostProcessService: SpaceCreationPostProcessServiceProtocol { + + // MARK: - Properties + + // MARK: Private + + private let session: MXSession + private let creationParams: SpaceCreationParameters + + private var tasks: [SpaceCreationPostProcessTask] = [] + private var currentTaskIndex = 0 + private var isRetry = false + + private(set) var createdSpace: MXSpace? { + didSet { + createdSpaceId = createdSpace?.spaceId + } + } + private(set) var createdSpaceId: String? + private var createdRoomsByName: [String: MXRoom] = [:] + + private var currentSubTaskIndex = 0 + + private var processingQueue = DispatchQueue(label: "org.matrix.sdk.MXSpace.processingQueue", attributes: .concurrent) + + private lazy var stateEventBuilder: MXRoomInitialStateEventBuilder = { + return MXRoomInitialStateEventBuilder() + }() + + private lazy var mediaUploader: MXMediaLoader = { + return MXMediaManager.prepareUploader(withMatrixSession: session, initialRange: 0, andRange: 1.0) + }() + + // MARK: Public + + private(set) var tasksSubject: CurrentValueSubject<[SpaceCreationPostProcessTask], Never> + + // MARK: - Setup + + init(session: MXSession, creationParams: SpaceCreationParameters) { + self.session = session + self.creationParams = creationParams + self.tasks = Self.tasks(with: creationParams) + self.tasksSubject = CurrentValueSubject(tasks) + } + + deinit { + } + + // MARK: - Public + + func run() { + self.isRetry = self.currentTaskIndex > 0 + self.currentTaskIndex = -1 + runNextTask() + } + + // MARK: - Private + + private static func tasks(with creationParams: SpaceCreationParameters) -> [SpaceCreationPostProcessTask] { + guard let spaceName = creationParams.name else { + MXLog.error("[SpaceCreationPostProcessService] setupTasks: space name shouldn't be nil") + return [] + } + + var tasks = [SpaceCreationPostProcessTask(type: .createSpace, title: VectorL10n.spacesCreationPostProcessCreatingSpaceTask(spaceName), state: .none)] + if creationParams.userSelectedAvatar != nil { + tasks.append(SpaceCreationPostProcessTask(type: .uploadAvatar, title: VectorL10n.spacesCreationPostProcessUploadingAvatar, state: .none)) + } + if creationParams.addedRoomIds.isEmpty { + tasks.append(contentsOf: creationParams.newRooms.compactMap({ room in + guard !room.name.isEmpty else { + return nil + } + + return SpaceCreationPostProcessTask(type: .createRoom(room.name), title: VectorL10n.spacesCreationPostProcessCreatingRoom(room.name), state: .none) + })) + } else { + let subTasks = creationParams.addedRoomIds.map { roomId in + SpaceCreationPostProcessTask(type: .addRooms, title: roomId, state: .none) + } + tasks.append(SpaceCreationPostProcessTask(type: .addRooms, title: VectorL10n.spacesCreationPostProcessAddingRooms("\(creationParams.addedRoomIds.count)"), state: .none, subTasks: subTasks)) + } + + if creationParams.userIdInvites.isEmpty { + let emailInviteCount = creationParams.userDefinedEmailInvites.count + if emailInviteCount > 0 { + let subTasks = creationParams.userDefinedEmailInvites.map { emailAddress in + SpaceCreationPostProcessTask(type: .inviteUsersByEmail, title: emailAddress, state: .none) + } + + tasks.append(SpaceCreationPostProcessTask(type: .inviteUsersByEmail, title: VectorL10n.spacesCreationPostProcessInvitingUsers("\(creationParams.userDefinedEmailInvites.count)"), state: .none, subTasks: subTasks)) + } + } + + return tasks + } + + private func runNextTask() { + currentTaskIndex += 1 + guard currentTaskIndex < tasks.count else { + return + } + + let task = tasks[currentTaskIndex] + + guard !task.isFinished || task.state == .failure else { + runNextTask() + return + } + +// fakeTaskExecution(task: task) +// return + + switch task.type { + case .createSpace: + createSpace(andUpdate: task) + case .uploadAvatar: + uploadAvatar(andUpdate: task) + case .addRooms: + addRooms(andUpdate: task) + case .createRoom(let roomName): + if let room = createdRoomsByName[roomName] { + addToSpace(room: room) + } else { + createRoom(withName: roomName, andUpdate: task) + } + case .inviteUsersByEmail: + inviteUsersByEmail(andUpdate: task) + } + } + + private func createSpace(andUpdate task: SpaceCreationPostProcessTask) { + let parameters = MXSpaceCreationParameters() + parameters.name = creationParams.name + parameters.topic = creationParams.topic + parameters.preset = creationParams.isPublic ? kMXRoomPresetPublicChat : kMXRoomPresetPrivateChat + parameters.visibility = creationParams.isPublic ? kMXRoomDirectoryVisibilityPublic : kMXRoomDirectoryVisibilityPrivate + if creationParams.isPublic { + var alias = creationParams.address + if let userDefinedAlias = creationParams.userDefinedAddress { + alias = userDefinedAlias + } + parameters.roomAlias = alias?.fullLocalAlias(with: session) + let guestAccessStateEvent = self.stateEventBuilder.buildGuestAccessEvent(withAccess: .canJoin) + parameters.addOrUpdateInitialStateEvent(guestAccessStateEvent) + let historyVisibilityStateEvent = self.stateEventBuilder.buildHistoryVisibilityEvent(withVisibility: .worldReadable) + parameters.addOrUpdateInitialStateEvent(historyVisibilityStateEvent) + } + parameters.inviteArray = creationParams.userIdInvites + + updateCurrentTask(with: .started) + session.spaceService.createSpace(with: parameters) { [weak self] response in + guard let self = self else { return } + if response.isFailure { + self.updateCurrentTask(with: .failure) + } else { + self.updateCurrentTask(with: .success) + self.createdSpace = response.value + self.runNextTask() + } + } + } + + private func uploadAvatar(andUpdate task: SpaceCreationPostProcessTask) { + self.updateCurrentTask(with: .started) + + guard let avatar = creationParams.userSelectedAvatar, let spaceRoom = self.createdSpace?.room else { + self.updateCurrentTask(with: .success) + self.runNextTask() + return + } + + 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 { return } + guard let url = URL(string: urlString) else { return } + + self.setAvatar(ofRoom: spaceRoom, withURL: url, andUpdate: task) + }, + failure: { [weak self] (error) in + guard let self = self else { return } + + self.updateCurrentTask(with: .failure) + self.runNextTask() + }) + } + + private func setAvatar(ofRoom room: MXRoom, withURL url: URL, andUpdate task: SpaceCreationPostProcessTask) { + room.setAvatar(url: url) { [weak self] (response) in + guard let self = self else { return } + + self.updateCurrentTask(with: response.isSuccess ? .success: .failure) + self.runNextTask() + } + } + + private func createRoom(withName roomName: String, andUpdate task: SpaceCreationPostProcessTask) { + let parameters = MXRoomCreationParameters() + parameters.name = roomName + parameters.visibility = creationParams.isPublic ? kMXRoomDirectoryVisibilityPublic : kMXRoomDirectoryVisibilityPrivate + parameters.preset = creationParams.isPublic ? kMXRoomPresetPublicChat : kMXRoomPresetPrivateChat + + updateCurrentTask(with: .started) + session.createRoom(parameters: parameters) { [weak self] response in + guard let self = self else { return } + + guard response.isSuccess, let createdRoom = response.value else { + self.updateCurrentTask(with: .failure) + self.runNextTask() + return + } + + self.createdRoomsByName[roomName] = createdRoom + self.addToSpace(room: createdRoom) + } + } + + private func addToSpace(room: MXRoom) { + self.createdSpace?.addChild(roomId: room.matrixItemId, completion: { response in + self.updateCurrentTask(with: response.isFailure ? .failure : .success) + self.runNextTask() + }) + } + + private func addRooms(andUpdate task: SpaceCreationPostProcessTask) { + updateCurrentTask(with: .started) + currentSubTaskIndex = -1 + addNextExistingRoom() + } + + private func inviteUsersByEmail(andUpdate task: SpaceCreationPostProcessTask) { + updateCurrentTask(with: .started) + currentSubTaskIndex = -1 + inviteNextUserByEmail() + } + + private func inviteNextUserByEmail() { + guard let createdSpace = self.createdSpace, let room = createdSpace.room else { + updateCurrentTask(with: .failure) + runNextTask() + return + } + + currentSubTaskIndex += 1 + + guard currentSubTaskIndex < tasks[currentTaskIndex].subTasks.count else { + let isSuccess = tasks[currentTaskIndex].subTasks.reduce(true, { $0 && $1.state == .success }) + updateCurrentTask(with: isSuccess ? .success : .failure) + runNextTask() + return + } + + room.invite(.email(creationParams.emailInvites[currentSubTaskIndex])) { [weak self] response in + guard let self = self else { return } + + self.tasks[self.currentTaskIndex].subTasks[self.currentSubTaskIndex].state = response.isSuccess ? .success : .failure + self.inviteNextUserByEmail() + } + } + + private func addNextExistingRoom() { + guard let createdSpace = self.createdSpace else { + updateCurrentTask(with: .failure) + runNextTask() + return + } + + currentSubTaskIndex += 1 + + guard currentSubTaskIndex < tasks[currentTaskIndex].subTasks.count else { + let isSuccess = tasks[currentTaskIndex].subTasks.reduce(true, { $0 && $1.state == .success }) + updateCurrentTask(with: isSuccess ? .success : .failure) + runNextTask() + return + } + + createdSpace.addChild(roomId: creationParams.addedRoomIds[currentSubTaskIndex], completion: { [weak self] response in + guard let self = self else { return } + + self.tasks[self.currentTaskIndex].subTasks[self.currentSubTaskIndex].state = response.isSuccess ? .success : .failure + self.addNextExistingRoom() + }) + } + + private func fakeTaskExecution(task: SpaceCreationPostProcessTask) { + updateCurrentTask(with: .started) + processingQueue.async { + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + self.updateCurrentTask(with: .success) + self.runNextTask() + } + } + } + + private func updateCurrentTask(with state: SpaceCreationPostProcessTaskState) { + guard currentTaskIndex < tasks.count else { + return + } + + tasks[currentTaskIndex].state = state + self.tasksSubject.send(tasks) + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Service/Mock/MockSpaceCreationPostProcessScreenState.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Service/Mock/MockSpaceCreationPostProcessScreenState.swift new file mode 100644 index 000000000..233aefb9f --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Service/Mock/MockSpaceCreationPostProcessScreenState.swift @@ -0,0 +1,56 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationPostProcess SpaceCreationPostProcess +// +// 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 MockSpaceCreationPostProcessScreenState: MockScreenState { + static var screenStates: [MockScreenState] = [MockSpaceCreationPostProcessScreenState.tasks] + + + // A case for each state you want to represent + // with specific, minimal associated data that will allow you + // mock that screen. + case tasks + + /// The associated screen + var screenType: Any.Type { + SpaceCreationPostProcess.self + } + + /// Generate the view struct for the screen state. + var screenView: ([Any], AnyView) { + let service: MockSpaceCreationPostProcessService + switch self { + case .tasks: + service = MockSpaceCreationPostProcessService() + } + let viewModel = SpaceCreationPostProcessViewModel.makeSpaceCreationPostProcessViewModel(spaceCreationPostProcessService: service) + + // can simulate service and viewModel actions here if needs be. + + return ( + [service, viewModel], + AnyView(SpaceCreationPostProcess(viewModel: viewModel.context) + .addDependency(MockAvatarService.example)) + ) + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Service/Mock/MockSpaceCreationPostProcessService.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Service/Mock/MockSpaceCreationPostProcessService.swift new file mode 100644 index 000000000..e22bb1979 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Service/Mock/MockSpaceCreationPostProcessService.swift @@ -0,0 +1,50 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationPostProcess SpaceCreationPostProcess +// +// 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 MockSpaceCreationPostProcessService: SpaceCreationPostProcessServiceProtocol { + + var tasksSubject: CurrentValueSubject<[SpaceCreationPostProcessTask], Never> + private(set) var createdSpaceId: String? + + init( + tasks: [SpaceCreationPostProcessTask] = [ + SpaceCreationPostProcessTask(type: .createSpace, title: "Space creation", state: .success), + SpaceCreationPostProcessTask(type: .createRoom("Room#1"), title: "Room#1 creation", state: .failure), + SpaceCreationPostProcessTask(type: .createRoom("Room#2"), title: "Room#2 creation", state: .started), + SpaceCreationPostProcessTask(type: .createRoom("Room#3"), title: "Room#3 creation", state: .none) + ] + ) { + self.tasksSubject = CurrentValueSubject<[SpaceCreationPostProcessTask], Never>(tasks) + } + + func simulateUpdate(presence: SpaceCreationPostProcessPresence) { + self.tasksSubject.send([ + SpaceCreationPostProcessTask(type: .createSpace, title: "Space creation", state: .success), + SpaceCreationPostProcessTask(type: .createRoom("Room#1"), title: "Room#1 creation", state: .failure), + SpaceCreationPostProcessTask(type: .createRoom("Room#2"), title: "Room#2 creation", state: .success), + SpaceCreationPostProcessTask(type: .createRoom("Room#3"), title: "Room#3 creation", state: .started) + ]) + } + + func run() { + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Service/SpaceCreationPostProcessServiceProtocol.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Service/SpaceCreationPostProcessServiceProtocol.swift new file mode 100644 index 000000000..2a2c13f65 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Service/SpaceCreationPostProcessServiceProtocol.swift @@ -0,0 +1,27 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationPostProcess SpaceCreationPostProcess +// +// 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, *) +protocol SpaceCreationPostProcessServiceProtocol: AnyObject { + var tasksSubject: CurrentValueSubject<[SpaceCreationPostProcessTask], Never> { get } + var createdSpaceId: String? { get } + func run() +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Test/UI/SpaceCreationPostProcessUITests.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Test/UI/SpaceCreationPostProcessUITests.swift new file mode 100644 index 000000000..5d6c1cfa4 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Test/UI/SpaceCreationPostProcessUITests.swift @@ -0,0 +1,55 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationPostProcess SpaceCreationPostProcess +// +// 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 SpaceCreationPostProcessUITests: MockScreenTest { + + override class var screenType: MockScreenState.Type { + return MockSpaceCreationPostProcessScreenState.self + } + + override class func createTest() -> MockScreenTest { + return SpaceCreationPostProcessUITests(selector: #selector(verifySpaceCreationPostProcessScreen)) + } + + func verifySpaceCreationPostProcessScreen() throws { + guard let screenState = screenState as? MockSpaceCreationPostProcessScreenState else { fatalError("no screen") } + switch screenState { + case .presence(let presence): + verifySpaceCreationPostProcessPresence(presence: presence) + case .longDisplayName(let name): + verifySpaceCreationPostProcessLongName(name: name) + } + } + + func verifySpaceCreationPostProcessPresence(presence: SpaceCreationPostProcessPresence) { + let presenceText = app.staticTexts["presenceText"] + XCTAssert(presenceText.exists) + XCTAssertEqual(presenceText.label, presence.title) + } + + func verifySpaceCreationPostProcessLongName(name: String) { + let displayNameText = app.staticTexts["displayNameText"] + XCTAssert(displayNameText.exists) + XCTAssertEqual(displayNameText.label, name) + } + +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Test/Unit/SpaceCreationPostProcessViewModelTests.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Test/Unit/SpaceCreationPostProcessViewModelTests.swift new file mode 100644 index 000000000..1c7ac6952 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Test/Unit/SpaceCreationPostProcessViewModelTests.swift @@ -0,0 +1,59 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationPostProcess SpaceCreationPostProcess +// +// 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 SpaceCreationPostProcessViewModelTests: XCTestCase { + private enum Constants { + static let presenceInitialValue: SpaceCreationPostProcessPresence = .offline + static let displayName = "Alice" + } + var service: MockSpaceCreationPostProcessService! + var viewModel: SpaceCreationPostProcessViewModelProtocol! + var context: SpaceCreationPostProcessViewModelType.Context! + var cancellables = Set() + override func setUpWithError() throws { + service = MockSpaceCreationPostProcessService(displayName: Constants.displayName, presence: Constants.presenceInitialValue) + viewModel = SpaceCreationPostProcessViewModel.makeSpaceCreationPostProcessViewModel(spaceCreationPostProcessService: service) + context = viewModel.context + } + + func testInitialState() { + XCTAssertEqual(context.viewState.displayName, Constants.displayName) + XCTAssertEqual(context.viewState.presence, Constants.presenceInitialValue) + } + + func testFirstPresenceReceived() throws { + let presencePublisher = context.$viewState.map(\.presence).removeDuplicates().collect(1).first() + XCTAssertEqual(try xcAwait(presencePublisher), [Constants.presenceInitialValue]) + } + + func testPresenceUpdatesReceived() throws { + let presencePublisher = context.$viewState.map(\.presence).removeDuplicates().collect(3).first() + let awaitDeferred = xcAwaitDeferred(presencePublisher) + let newPresenceValue1: SpaceCreationPostProcessPresence = .online + let newPresenceValue2: SpaceCreationPostProcessPresence = .idle + service.simulateUpdate(presence: newPresenceValue1) + service.simulateUpdate(presence: newPresenceValue2) + XCTAssertEqual(try awaitDeferred(), [Constants.presenceInitialValue, newPresenceValue1, newPresenceValue2]) + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/View/SpaceCreationPostProcess.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/View/SpaceCreationPostProcess.swift new file mode 100644 index 000000000..ab53829aa --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/View/SpaceCreationPostProcess.swift @@ -0,0 +1,83 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationPostProcess SpaceCreationPostProcess +// +// 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 SpaceCreationPostProcess: View { + + // MARK: - Properties + + // MARK: Private + + @Environment(\.theme) private var theme: ThemeSwiftUI + + // MARK: Public + + @ObservedObject var viewModel: SpaceCreationPostProcessViewModel.Context + + var body: some View { + VStack { + Spacer() + VStack(spacing: 13) { + ProgressView() + .isHidden(viewModel.viewState.isFinished) + .scaleEffect(1.5, anchor: .center) + .progressViewStyle(CircularProgressViewStyle(tint: theme.colors.secondaryContent)) + Text(VectorL10n.spacesCreationPostProcessCreatingSpace) + .font(theme.fonts.calloutSB) + .foregroundColor(theme.colors.secondaryContent) + } + Spacer() + VStack(alignment: .leading, spacing: 11) { + ForEach(viewModel.viewState.tasks.indices) { index in + SpaceCreationPostProcessItem(title: viewModel.viewState.tasks[index].title, state: viewModel.viewState.tasks[index].state) + } + } + Spacer() + HStack { + ThemableButton(icon: nil, title: VectorL10n.done) { + viewModel.send(viewAction: .cancel) + } + ThemableButton(icon: nil, title: VectorL10n.retry) { + viewModel.send(viewAction: .retry) + } + } + .isHidden(!viewModel.viewState.isFinished || viewModel.viewState.errorCount == 0) + } + .animation(.easeIn(duration: 0.2), value: viewModel.viewState.errorCount) + .padding(EdgeInsets(top: 24, leading: 16, bottom: 24, trailing: 16)) + .navigationBarHidden(true) + .background(theme.colors.background) + .frame(maxHeight: .infinity) + .onAppear() { + viewModel.send(viewAction: .runTasks) + } + } +} + +// MARK: - Previews + +@available(iOS 14.0, *) +struct SpaceCreationPostProcess_Previews: PreviewProvider { + static let stateRenderer = MockSpaceCreationPostProcessScreenState.stateRenderer + static var previews: some View { + stateRenderer.screenGroup(addNavigation: true).theme(.light).preferredColorScheme(.light) + stateRenderer.screenGroup(addNavigation: true).theme(.dark).preferredColorScheme(.dark) + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/View/SpaceCreationPostProcessItem.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/View/SpaceCreationPostProcessItem.swift new file mode 100644 index 000000000..c14d95696 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/View/SpaceCreationPostProcessItem.swift @@ -0,0 +1,87 @@ +// +// 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 SpaceCreationPostProcessItem: View { + // MARK: - Properties + + let title: String + let state: SpaceCreationPostProcessTaskState + + // MARK: Private + + @Environment(\.theme) private var theme: ThemeSwiftUI + private var tintColor: Color { + switch state { + case .none: + return theme.colors.quinaryContent + case .started: + return theme.colors.primaryContent + case .success: + return theme.colors.tertiaryContent + case .failure: + return theme.colors.alert + } + } + + // MARK: Public + + var body: some View { + HStack { + switch state { + case .none: + Image(systemName: "circle").renderingMode(.template).foregroundColor(theme.colors.tertiaryContent) + case .started: + ProgressView().progressViewStyle(CircularProgressViewStyle(tint: theme.colors.secondaryContent)).scaleEffect(0.9, anchor: .center) + Spacer().frame(width: 6) + case .success: + Image(systemName: "checkmark.circle.fill").renderingMode(.template).foregroundColor(theme.colors.tertiaryContent) + case .failure: + Image(systemName: "exclamationmark.circle.fill").renderingMode(.template).foregroundColor(theme.colors.alert) + } + Text(title) + .font(theme.fonts.callout) + .foregroundColor(state == .started ? theme.colors.primaryContent : theme.colors.tertiaryContent) + } + .opacity(state == .none ? 0.5 : 1) + .animation(.easeOut(duration: 0.2), value: state) + } +} + +// MARK: - Previews + +@available(iOS 14.0, *) +struct SpaceCreationPostProcessItem_Previews: PreviewProvider { + static var previews: some View { + Group { + VStack(alignment: .leading, spacing: 20) { + SpaceCreationPostProcessItem(title: "failed task", state: .failure) + SpaceCreationPostProcessItem(title: "not started", state: .none) + SpaceCreationPostProcessItem(title: "on going task ", state: .started) + SpaceCreationPostProcessItem(title: "succesful task", state: .success) + } + VStack(alignment: .leading, spacing: 20) { + SpaceCreationPostProcessItem(title: "failed task", state: .failure) + SpaceCreationPostProcessItem(title: "not started", state: .none) + SpaceCreationPostProcessItem(title: "on going task ", state: .started) + SpaceCreationPostProcessItem(title: "succesful task", state: .success) + }.theme(.dark).preferredColorScheme(.dark) + } + .padding() + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/ViewModel/SpaceCreationPostProcessViewModel.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/ViewModel/SpaceCreationPostProcessViewModel.swift new file mode 100644 index 000000000..7e378c6f5 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/ViewModel/SpaceCreationPostProcessViewModel.swift @@ -0,0 +1,140 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationPostProcess SpaceCreationPostProcess +// +// 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 SpaceCreationPostProcessViewModelType = StateStoreViewModel +@available(iOS 14, *) +class SpaceCreationPostProcessViewModel: SpaceCreationPostProcessViewModelType, SpaceCreationPostProcessViewModelProtocol { + + // MARK: - Properties + + // MARK: Private + + private let spaceCreationPostProcessService: SpaceCreationPostProcessServiceProtocol + private var updateNotificationObserver: Any? + + // MARK: Public + + var completion: ((SpaceCreationPostProcessViewModelResult) -> Void)? + + // MARK: - Setup + + static func makeSpaceCreationPostProcessViewModel(spaceCreationPostProcessService: SpaceCreationPostProcessServiceProtocol) -> SpaceCreationPostProcessViewModelProtocol { + return SpaceCreationPostProcessViewModel(spaceCreationPostProcessService: spaceCreationPostProcessService) + } + + private init(spaceCreationPostProcessService: SpaceCreationPostProcessServiceProtocol) { + self.spaceCreationPostProcessService = spaceCreationPostProcessService + super.init(initialViewState: Self.defaultState(spaceCreationPostProcessService: spaceCreationPostProcessService)) + setupTasksObserving() + } + + private static func defaultState(spaceCreationPostProcessService: SpaceCreationPostProcessServiceProtocol) -> SpaceCreationPostProcessViewState { + let tasks = spaceCreationPostProcessService.tasksSubject.value + return SpaceCreationPostProcessViewState( + tasks: tasks, + isFinished: tasks.first?.state == .failure || tasks.reduce(true, { result, task in result && task.isFinished }), + errorCount: tasks.reduce(0, { result, task in result + (task.state == .failure ? 1 : 0) }) + ) + } + + private func setupTasksObserving() { + let tasksUpdatePublisher = spaceCreationPostProcessService.tasksSubject + .map(SpaceCreationPostProcessStateAction.updateTasks) + .eraseToAnyPublisher() + dispatch(actionPublisher: tasksUpdatePublisher) + updateNotificationObserver = NotificationCenter.default.addObserver(forName: SpaceCreationPostProcessViewModel.didUpdate, object: nil, queue: OperationQueue.main) { [weak self] notification in + guard let self = self else { + return + } + + guard let state = notification.userInfo?[SpaceCreationPostProcessViewModel.newStateKey] as? SpaceCreationPostProcessViewState else { + return + } + + if state.isFinished && state.errorCount == 0 { + guard let spaceId = self.spaceCreationPostProcessService.createdSpaceId else { + self.cancel() + return + } + + self.done(spaceId: spaceId) + } + } + } + + deinit { + if let updateNotificationObserver = self.updateNotificationObserver { + NotificationCenter.default.removeObserver(updateNotificationObserver) + } + } + + // MARK: - Public + + override func process(viewAction: SpaceCreationPostProcessViewAction) { + switch viewAction { + case .cancel: + cancel() + case .runTasks: + runTasks() + case .retry: + runTasks() + } + } + + override class func reducer(state: inout SpaceCreationPostProcessViewState, action: SpaceCreationPostProcessStateAction) { + switch action { + case .updateTasks(let tasks): + state.tasks = tasks + state.isFinished = tasks.first?.state == .failure || tasks.reduce(true, { result, task in result && task.isFinished }) + state.errorCount = tasks.reduce(0, { result, task in result + (task.state == .failure ? 1 : 0) }) + } + + NotificationCenter.default.post(name: SpaceCreationPostProcessViewModel.didUpdate, object: nil, userInfo: [SpaceCreationPostProcessViewModel.newStateKey : state]) + + UILog.debug("[SpaceCreationPostProcessViewModel] reducer with action \(action) produced state: \(state)") + } + + private func done(spaceId: String) { + completion?(.done(spaceId)) + } + + private func cancel() { + completion?(.cancel) + } + + private func runTasks() { + spaceCreationPostProcessService.run() + } +} + +// MARK: - MXSpaceService notification constants +@available(iOS 14, *) +extension SpaceCreationPostProcessViewModel { + /// Posted once the process is finished + public static let didUpdate = Notification.Name("SpaceCreationPostProcessViewModelDidUpdate") + + public static let newStateKey = "newState" +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/ViewModel/SpaceCreationPostProcessViewModelProtocol.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/ViewModel/SpaceCreationPostProcessViewModelProtocol.swift new file mode 100644 index 000000000..13420e655 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/ViewModel/SpaceCreationPostProcessViewModelProtocol.swift @@ -0,0 +1,28 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationPostProcess SpaceCreationPostProcess +// +// 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 SpaceCreationPostProcessViewModelProtocol { + + var completion: ((SpaceCreationPostProcessViewModelResult) -> Void)? { get set } + @available(iOS 14, *) + static func makeSpaceCreationPostProcessViewModel(spaceCreationPostProcessService: SpaceCreationPostProcessServiceProtocol) -> SpaceCreationPostProcessViewModelProtocol + @available(iOS 14, *) + var context: SpaceCreationPostProcessViewModelType.Context { get } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Coordinator/SpaceCreationRoomsCoordinator.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Coordinator/SpaceCreationRoomsCoordinator.swift new file mode 100644 index 000000000..6d48d496e --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Coordinator/SpaceCreationRoomsCoordinator.swift @@ -0,0 +1,71 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationRooms SpaceCreationRooms +/* + 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 UIKit +import SwiftUI + +final class SpaceCreationRoomsCoordinator: Coordinator, Presentable { + + // MARK: - Properties + + // MARK: Private + + private let parameters: SpaceCreationRoomsCoordinatorParameters + private let spaceCreationRoomsHostingController: UIViewController + private var spaceCreationRoomsViewModel: SpaceCreationRoomsViewModelProtocol + + // MARK: Public + + // Must be used only internally + var childCoordinators: [Coordinator] = [] + var callback: ((SpaceCreationRoomsCoordinatorAction) -> Void)? + + // MARK: - Setup + + @available(iOS 14.0, *) + init(parameters: SpaceCreationRoomsCoordinatorParameters) { + self.parameters = parameters + let viewModel = SpaceCreationRoomsViewModel(creationParameters: parameters.creationParams) + let view = SpaceCreationRooms(viewModel: viewModel.context) + .addDependency(AvatarService.instantiate(mediaManager: parameters.session.mediaManager)) + spaceCreationRoomsViewModel = viewModel + let hostingController = VectorHostingController(rootView: view) + hostingController.hidesBackTitleWhenPushed = true + spaceCreationRoomsHostingController = hostingController + } + + // MARK: - Public + func start() { + MXLog.debug("[SpaceCreationRoomsCoordinator] did start.") + spaceCreationRoomsViewModel.callback = { [weak self] result in + MXLog.debug("[SpaceCreationRoomsCoordinator] SpaceCreationRoomsViewModel did complete with result: \(result).") + guard let self = self else { return } + switch result { + case .cancel: + self.callback?(.cancel) + case .done: + self.callback?(.didSetupRooms) + } + } + } + + func toPresentable() -> UIViewController { + return self.spaceCreationRoomsHostingController + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Coordinator/SpaceCreationRoomsCoordinatorParameters.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Coordinator/SpaceCreationRoomsCoordinatorParameters.swift new file mode 100644 index 000000000..6e095f71b --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Coordinator/SpaceCreationRoomsCoordinatorParameters.swift @@ -0,0 +1,24 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationRooms SpaceCreationRooms +// +// 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 + +struct SpaceCreationRoomsCoordinatorParameters { + let session: MXSession + let creationParams: SpaceCreationParameters +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Model/SpaceCreationRoomsCoordinatorAction.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Model/SpaceCreationRoomsCoordinatorAction.swift new file mode 100644 index 000000000..f851c2085 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Model/SpaceCreationRoomsCoordinatorAction.swift @@ -0,0 +1,22 @@ +// +// 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 + +enum SpaceCreationRoomsCoordinatorAction { + case cancel + case didSetupRooms +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Model/SpaceCreationRoomsPresence.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Model/SpaceCreationRoomsPresence.swift new file mode 100644 index 000000000..3790e6207 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Model/SpaceCreationRoomsPresence.swift @@ -0,0 +1,44 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationRooms SpaceCreationRooms +// +// 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 + +enum SpaceCreationRoomsPresence { + case online + case idle + case offline +} + +extension SpaceCreationRoomsPresence { + var title: String { + switch self { + case .online: + return VectorL10n.roomParticipantsOnline + case .idle: + return VectorL10n.roomParticipantsIdle + case .offline: + return VectorL10n.roomParticipantsOffline + } + } +} + +extension SpaceCreationRoomsPresence: CaseIterable { } + +extension SpaceCreationRoomsPresence: Identifiable { + var id: Self { self } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Model/SpaceCreationRoomsStateAction.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Model/SpaceCreationRoomsStateAction.swift new file mode 100644 index 000000000..3515e1250 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Model/SpaceCreationRoomsStateAction.swift @@ -0,0 +1,22 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationRooms SpaceCreationRooms +// +// 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 + +enum SpaceCreationRoomsStateAction { +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Model/SpaceCreationRoomsViewAction.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Model/SpaceCreationRoomsViewAction.swift new file mode 100644 index 000000000..752dd768e --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Model/SpaceCreationRoomsViewAction.swift @@ -0,0 +1,24 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationRooms SpaceCreationRooms +// +// 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 + +enum SpaceCreationRoomsViewAction { + case cancel + case done +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Model/SpaceCreationRoomsViewModelBindings.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Model/SpaceCreationRoomsViewModelBindings.swift new file mode 100644 index 000000000..f9d2ad6e7 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Model/SpaceCreationRoomsViewModelBindings.swift @@ -0,0 +1,22 @@ +// +// 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 + +/// State bound directly to SwiftUI elements. +struct SpaceCreationRoomsViewModelBindings { + var rooms: [SpaceCreationNewRoom] +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Model/SpaceCreationRoomsViewModelResult.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Model/SpaceCreationRoomsViewModelResult.swift new file mode 100644 index 000000000..da6697d96 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Model/SpaceCreationRoomsViewModelResult.swift @@ -0,0 +1,24 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationRooms SpaceCreationRooms +// +// 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 + +enum SpaceCreationRoomsViewModelResult { + case cancel + case done +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Model/SpaceCreationRoomsViewState.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Model/SpaceCreationRoomsViewState.swift new file mode 100644 index 000000000..81bf820ec --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Model/SpaceCreationRoomsViewState.swift @@ -0,0 +1,24 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationRooms SpaceCreationRooms +// +// 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 + +struct SpaceCreationRoomsViewState: BindableState { + let title: String + var bindings: SpaceCreationRoomsViewModelBindings +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Service/Mock/MockSpaceCreationRoomsScreenState.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Service/Mock/MockSpaceCreationRoomsScreenState.swift new file mode 100644 index 000000000..110e4a6f0 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Service/Mock/MockSpaceCreationRoomsScreenState.swift @@ -0,0 +1,62 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationRooms SpaceCreationRooms +// +// 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 MockSpaceCreationRoomsScreenState: MockScreenState, CaseIterable { + // A case for each state you want to represent + // with specific, minimal associated data that will allow you + // mock that screen. + case defaultValues + case valuesEntered + + /// The associated screen + var screenType: Any.Type { + SpaceCreationRooms.self + } + + /// A list of screen state definitions + static var allCases: [MockSpaceCreationRoomsScreenState] { + [.defaultValues, .valuesEntered] + } + + /// Generate the view struct for the screen state. + var screenView: ([Any], AnyView) { + let creationParams = SpaceCreationParameters() + switch self { + case .defaultValues: break + case .valuesEntered: + for (index, room) in creationParams.newRooms.enumerated() { + creationParams.newRooms[index] = SpaceCreationNewRoom(name: "Room \(index + 1)", defaultName: room.defaultName) + } + } + let viewModel = SpaceCreationRoomsViewModel(creationParameters: creationParams) + + // can simulate service and viewModel actions here if needs be. + + return ( + [viewModel], + AnyView(SpaceCreationRooms(viewModel: viewModel.context) + .addDependency(MockAvatarService.example)) + ) + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Test/UI/SpaceCreationRoomsUITests.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Test/UI/SpaceCreationRoomsUITests.swift new file mode 100644 index 000000000..7cb20666e --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Test/UI/SpaceCreationRoomsUITests.swift @@ -0,0 +1,47 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationRooms SpaceCreationRooms +// +// 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 SpaceCreationRoomsUITests: MockScreenTest { + + override class var screenType: MockScreenState.Type { + return MockSpaceCreationRoomsScreenState.self + } + + override class func createTest() -> MockScreenTest { + return SpaceCreationRoomsUITests(selector: #selector(verifySpaceCreationRoomsScreen)) + } + + func verifySpaceCreationRoomsScreen() throws { + guard let screenState = screenState as? MockSpaceCreationRoomsScreenState else { fatalError("no screen") } + switch screenState { + case .defaultValues: + verifyValueTextFields() + case .valuesEntered: + verifyValueTextFields() + } + } + + func verifyValueTextFields() { + let emailTextFieldsCount = app.textFields.matching(identifier: "roomTextField").count + XCTAssertEqual(emailTextFieldsCount, 3) + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Test/Unit/SpaceCreationRoomsViewModelTests.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Test/Unit/SpaceCreationRoomsViewModelTests.swift new file mode 100644 index 000000000..2c53401ec --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Test/Unit/SpaceCreationRoomsViewModelTests.swift @@ -0,0 +1,39 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationRooms SpaceCreationRooms +// +// 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 SpaceCreationRoomsViewModelTests: XCTestCase { + var creationParameters = SpaceCreationParameters() + var viewModel: SpaceCreationRoomsViewModelProtocol! + var context: SpaceCreationRoomsViewModelType.Context! + + override func setUpWithError() throws { + viewModel = SpaceCreationRoomsViewModel(creationParameters: creationParameters) + context = viewModel.context + } + + func testInitialState() { + XCTAssertEqual(context.viewState.title, creationParameters.isPublic ? VectorL10n.spacesCreationPublicSpaceTitle : VectorL10n.spacesCreationPrivateSpaceTitle) + XCTAssertEqual(context.rooms, creationParameters.newRooms) + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/View/SpaceCreationRooms.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/View/SpaceCreationRooms.swift new file mode 100644 index 000000000..8f6581105 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/View/SpaceCreationRooms.swift @@ -0,0 +1,101 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationRooms SpaceCreationRooms +// +// 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 SpaceCreationRooms: View { + + // MARK: - Properties + + // MARK: Private + + @Environment(\.theme) private var theme: ThemeSwiftUI + + // MARK: Public + + @ObservedObject var viewModel: SpaceCreationRoomsViewModel.Context + + var body: some View { + VStack { + Text(VectorL10n.spacesCreationNewRoomsTitle) + .multilineTextAlignment(.center) + .font(theme.fonts.title3SB) + .foregroundColor(theme.colors.primaryContent) + Spacer().frame(height: 20) + Text(VectorL10n.spacesCreationNewRoomsMessage) + .multilineTextAlignment(.center) + .font(theme.fonts.body) + .foregroundColor(theme.colors.secondaryContent) + GeometryReader { reader in + ScrollView { + VStack { + Spacer() + roomNames + } + .padding(.horizontal, 2) + .frame(minHeight: reader.size.height - 2) + } + } + ThemableButton(icon: nil, title: VectorL10n.next) { + ResponderManager.resignFirstResponder() + viewModel.send(viewAction: .done) + } + } + .padding(EdgeInsets(top: 24, leading: 16, bottom: 24, trailing: 16)) + .background(theme.colors.background) + .navigationTitle(viewModel.viewState.title) + .configureNavigationBar{ + $0.navigationBar.shadowImage = UIImage() + $0.navigationBar.barTintColor = UIColor(theme.colors.background) + $0.navigationBar.tintColor = UIColor(theme.colors.secondaryContent) + } + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button(action: { + viewModel.send(viewAction: .cancel) + }) { + Image(uiImage: Asset.Images.spacesModalClose.image).renderingMode(.template) + } + } + } + } + + // MARK: - Private + + private var roomNames: some View { + VStack { + ForEach(viewModel.rooms.indices) { index in + RoundedBorderTextField(title: VectorL10n.spacesCreationNewRoomsRoomNameTitle, placeHolder: viewModel.rooms[index].defaultName, text: $viewModel.rooms[index].name, footerText: .constant(nil), isError: .constant(false), configuration: UIKitTextInputConfiguration( returnKeyType: index < viewModel.rooms.endIndex - 1 ? .next : .done)) + .accessibility(identifier: "roomTextField") + } + } + .padding(.bottom) + } +} + +// MARK: - Previews + +@available(iOS 14.0, *) +struct SpaceCreationRooms_Previews: PreviewProvider { + static let stateRenderer = MockSpaceCreationRoomsScreenState.stateRenderer + static var previews: some View { + stateRenderer.screenGroup(addNavigation: true).theme(.light).preferredColorScheme(.light) + stateRenderer.screenGroup(addNavigation: true).theme(.dark).preferredColorScheme(.dark) + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/ViewModel/SpaceCreationRoomsViewModel.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/ViewModel/SpaceCreationRoomsViewModel.swift new file mode 100644 index 000000000..2861075c9 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/ViewModel/SpaceCreationRoomsViewModel.swift @@ -0,0 +1,79 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationRooms SpaceCreationRooms +// +// 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 SpaceCreationRoomsViewModelType = StateStoreViewModel +@available(iOS 14, *) +class SpaceCreationRoomsViewModel: SpaceCreationRoomsViewModelType, SpaceCreationRoomsViewModelProtocol { + + // MARK: - Setup + + // MARK: Private + + private let creationParameters: SpaceCreationParameters + + // MARK: Public + + var callback: ((SpaceCreationRoomsViewModelResult) -> Void)? + + // MARK: - Setup + + init(creationParameters: SpaceCreationParameters) { + self.creationParameters = creationParameters + super.init(initialViewState: SpaceCreationRoomsViewModel.defaultState(creationParameters: creationParameters)) + } + + private static func defaultState(creationParameters: SpaceCreationParameters) -> SpaceCreationRoomsViewState { + let bindings = SpaceCreationRoomsViewModelBindings(rooms: creationParameters.newRooms) + return SpaceCreationRoomsViewState( + title: creationParameters.isPublic ? VectorL10n.spacesCreationPublicSpaceTitle : VectorL10n.spacesCreationPrivateSpaceTitle, + bindings: bindings + ) + } + + + // MARK: - Public + + override func process(viewAction: SpaceCreationRoomsViewAction) { + switch viewAction { + case .cancel: + cancel() + case .done: + done() + } + } + + override class func reducer(state: inout SpaceCreationRoomsViewState, action: SpaceCreationRoomsStateAction) { + } + + private func done() { + self.creationParameters.newRooms = self.context.rooms + callback?(.done) + } + + private func cancel() { + callback?(.cancel) + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/ViewModel/SpaceCreationRoomsViewModelProtocol.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/ViewModel/SpaceCreationRoomsViewModelProtocol.swift new file mode 100644 index 000000000..8406bd68e --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/ViewModel/SpaceCreationRoomsViewModelProtocol.swift @@ -0,0 +1,26 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationRooms SpaceCreationRooms +// +// 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 SpaceCreationRoomsViewModelProtocol { + + var callback: ((SpaceCreationRoomsViewModelResult) -> Void)? { get set } + @available(iOS 14, *) + var context: SpaceCreationRoomsViewModelType.Context { get } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Coordinator/SpaceCreationSettingsCoordinator.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Coordinator/SpaceCreationSettingsCoordinator.swift new file mode 100644 index 000000000..283849dfb --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Coordinator/SpaceCreationSettingsCoordinator.swift @@ -0,0 +1,102 @@ +// File created from TemplateAdvancedRoomsExample +// $ createSwiftUITwoScreen.sh Spaces/SpaceCreation SpaceCreation SpaceCreationMenu SpaceCreationSettings +/* + 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 UIKit +import SwiftUI + +final class SpaceCreationSettingsCoordinator: Coordinator, Presentable { + + // MARK: - Properties + + // MARK: Private + + private let parameters: SpaceCreationSettingsCoordinatorParameters + private let spaceCreationSettingsHostingController: UIViewController + private var spaceCreationSettingsViewModel: SpaceCreationSettingsViewModelProtocol + + 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 callback: ((SpaceCreationSettingsCoordinatorAction) -> Void)? + + // MARK: - Setup + + @available(iOS 14.0, *) + init(parameters: SpaceCreationSettingsCoordinatorParameters) { + self.parameters = parameters + let service = SpaceCreationSettingsService(roomName: parameters.creationParameters.name ?? "", userDefinedAddress: parameters.creationParameters.userDefinedAddress, session: parameters.session) + let viewModel = SpaceCreationSettingsViewModel(spaceCreationSettingsService: service, creationParameters: parameters.creationParameters) + let view = SpaceCreationSettings(viewModel: viewModel.context) + .addDependency(AvatarService.instantiate(mediaManager: parameters.session.mediaManager)) + spaceCreationSettingsViewModel = viewModel + let hostingController = VectorHostingController(rootView: view) + hostingController.hidesBackTitleWhenPushed = true + spaceCreationSettingsHostingController = hostingController + } + + // MARK: - Public + + func start() { + MXLog.debug("[SpaceCreationSettingsCoordinator] did start.") + spaceCreationSettingsViewModel.callback = { [weak self] result in + MXLog.debug("[SpaceCreationSettingsCoordinator] SpaceCreationSettingsViewModel did complete with result: \(result).") + guard let self = self else { return } + switch result { + case .done: + self.callback?(.didSetupParameters) + case .cancel: + self.callback?(.cancel) + case .pickImage(let sourceRect): + self.pickImage(from: sourceRect) + break + } + } + } + + func toPresentable() -> UIViewController { + return self.spaceCreationSettingsHostingController + } + + // 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 SpaceCreationSettingsCoordinator: SingleImagePickerPresenterDelegate { + func singleImagePickerPresenter(_ presenter: SingleImagePickerPresenter, didSelectImageData imageData: Data, withUTI uti: MXKUTI?) { + spaceCreationSettingsViewModel.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/SpaceCreation/SpaceCreationSettings/Coordinator/SpaceCreationSettingsCoordinatorParamaters.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Coordinator/SpaceCreationSettingsCoordinatorParamaters.swift new file mode 100644 index 000000000..f79a0c749 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Coordinator/SpaceCreationSettingsCoordinatorParamaters.swift @@ -0,0 +1,24 @@ +// File created from TemplateAdvancedRoomsExample +// $ createSwiftUITwoScreen.sh Spaces/SpaceCreation SpaceCreation SpaceCreationMenu SpaceCreationSettings +// +// 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 + +struct SpaceCreationSettingsCoordinatorParameters { + let session: MXSession + let creationParameters: SpaceCreationParameters +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Model/SpaceCreationSettingsAddressValidationStatus.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Model/SpaceCreationSettingsAddressValidationStatus.swift new file mode 100644 index 000000000..329c151e3 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Model/SpaceCreationSettingsAddressValidationStatus.swift @@ -0,0 +1,24 @@ +// +// 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 + +enum SpaceCreationSettingsAddressValidationStatus { + case none(_ address: String) + case valid(_ address: String) + case alreadyExists(_ address: String) + case invalidCharacters(_ address: String) +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Model/SpaceCreationSettingsCoordinatorAction.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Model/SpaceCreationSettingsCoordinatorAction.swift new file mode 100644 index 000000000..b78818f52 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Model/SpaceCreationSettingsCoordinatorAction.swift @@ -0,0 +1,22 @@ +// +// 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 + +enum SpaceCreationSettingsCoordinatorAction { + case cancel + case didSetupParameters +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Model/SpaceCreationSettingsStateAction.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Model/SpaceCreationSettingsStateAction.swift new file mode 100644 index 000000000..670015eae --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Model/SpaceCreationSettingsStateAction.swift @@ -0,0 +1,29 @@ +// File created from TemplateAdvancedRoomsExample +// $ createSwiftUITwoScreen.sh Spaces/SpaceCreation SpaceCreation SpaceCreationMenu SpaceCreationSettings +// +// 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 UIKit + +/// Actions to be performed on the `ViewModel` State +enum SpaceCreationSettingsStateAction { + case updateRoomNameError(String?) + case updateRoomDefaultAddress(String) + case updateAddressValidationStatus(SpaceCreationSettingsAddressValidationStatus) + case updateAvatar(AvatarInputProtocol) + case updateAvatarImage(UIImage?) +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Model/SpaceCreationSettingsViewAction.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Model/SpaceCreationSettingsViewAction.swift new file mode 100644 index 000000000..837e265dd --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Model/SpaceCreationSettingsViewAction.swift @@ -0,0 +1,30 @@ +// File created from TemplateAdvancedRoomsExample +// $ createSwiftUITwoScreen.sh Spaces/SpaceCreation SpaceCreation SpaceCreationMenu SpaceCreationSettings +// +// 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 UIKit + +/// Actions send from the `View` to the `ViewModel`. +enum SpaceCreationSettingsViewAction { + case cancel + case done + case pickImage(_ sourceRect: CGRect) + case nameChanged(_ newValue: String) + case addressChanged(_ newValue: String) + case topicChanged(_ newValue: String) +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Model/SpaceCreationSettingsViewModelAction.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Model/SpaceCreationSettingsViewModelAction.swift new file mode 100644 index 000000000..ee2db0655 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Model/SpaceCreationSettingsViewModelAction.swift @@ -0,0 +1,27 @@ +// File created from TemplateAdvancedRoomsExample +// $ createSwiftUITwoScreen.sh Spaces/SpaceCreation SpaceCreation SpaceCreationMenu SpaceCreationSettings +// +// 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 UIKit + +/// Actions sent by the `ViewModel` to the `Coordinator` +enum SpaceCreationSettingsViewModelAction { + case done + case cancel + case pickImage(_ sourceRect: CGRect) +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Model/SpaceCreationSettingsViewModelBindings.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Model/SpaceCreationSettingsViewModelBindings.swift new file mode 100644 index 000000000..0a1757979 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Model/SpaceCreationSettingsViewModelBindings.swift @@ -0,0 +1,26 @@ +// File created from TemplateAdvancedRoomsExample +// $ createSwiftUITwoScreen.sh Spaces/SpaceCreation SpaceCreation SpaceCreationMenu SpaceCreationSettings +// +// 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 + +/// State bound directly to SwiftUI elements. +struct SpaceCreationSettingsViewModelBindings { + var roomName: String + var topic: String + var address: String +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Model/SpaceCreationSettingsViewState.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Model/SpaceCreationSettingsViewState.swift new file mode 100644 index 000000000..663d968df --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Model/SpaceCreationSettingsViewState.swift @@ -0,0 +1,34 @@ +// File created from TemplateAdvancedRoomsExample +// $ createSwiftUITwoScreen.sh Spaces/SpaceCreation SpaceCreation SpaceCreationMenu SpaceCreationSettings +// +// 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 UIKit + +/// State managed by the `ViewModel` delivered to the `View`. +struct SpaceCreationSettingsViewState: BindableState { + let title: String + let showRoomAddress: Bool + var defaultAddress: String + var roomNameError: String? + var addressMessage: String? + var isAddressValid: Bool + var avatar: AvatarInputProtocol + var avatarImage: UIImage? + var bindings: SpaceCreationSettingsViewModelBindings +} + diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Service/MatrixSDK/SpaceCreationSettingsService.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Service/MatrixSDK/SpaceCreationSettingsService.swift new file mode 100644 index 000000000..98e7b573f --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Service/MatrixSDK/SpaceCreationSettingsService.swift @@ -0,0 +1,136 @@ +// File created from TemplateAdvancedRoomsExample +// $ createSwiftUITwoScreen.sh Spaces/SpaceCreation SpaceCreation SpaceCreationMenu SpaceCreationSettings +// +// 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 SpaceCreationSettingsService: SpaceCreationSettingsServiceProtocol { + + // MARK: - Properties + + var roomName: String { + didSet { + updateDefaultAddress() + updateAvatar() + } + } + var userDefinedAddress: String? { + didSet { + validateAddress() + } + } + + // MARK: Private + + private let session: MXSession + private var defaultAddress: String { + didSet { + defaultAddressSubject.send(defaultAddress) + validateAddress() + } + } + private var lastValidatedAddress: String = "" + private var currentAddress: String? { + return self.userDefinedAddress?.count ?? 0 > 0 ? self.userDefinedAddress : defaultAddress + } + private var currentOperation: MXHTTPOperation? + + // MARK: Public + + private(set) var addressValidationSubject: CurrentValueSubject + private(set) var defaultAddressSubject: CurrentValueSubject + private(set) var avatarViewDataSubject: CurrentValueSubject + var isAddressValid: Bool { + switch addressValidationSubject.value { + case .none, .valid: + return true + default: + return false + } + } + + // MARK: - Setup + + init(roomName: String, userDefinedAddress: String?, session: MXSession) { + self.session = session + self.defaultAddress = "" + self.defaultAddressSubject = CurrentValueSubject(defaultAddress) + self.roomName = roomName + self.addressValidationSubject = CurrentValueSubject(.none("#")) + self.avatarViewDataSubject = CurrentValueSubject(AvatarInput(mxContentUri: userDefinedAddress, matrixItemId: "", displayName: roomName)) + + self.updateDefaultAddress() + self.validateAddress() + } + + deinit { + currentOperation?.cancel() + currentOperation = nil + } + + // MARK: Public + + // MARK: Private + + private func updateAvatar() { + self.avatarViewDataSubject.send(AvatarInput(mxContentUri: currentAddress, matrixItemId: "", displayName: roomName)) + } + + private func updateDefaultAddress() { + defaultAddress = roomName.toValidAliasLocalPart() + } + + private func validateAddress() { + currentOperation?.cancel() + currentOperation = nil + + guard let userDefinedAddress = self.userDefinedAddress, !userDefinedAddress.isEmpty else { + let fullAddress = defaultAddress.fullLocalAlias(with: session) + + if defaultAddress.isEmpty { + addressValidationSubject.send(.none(fullAddress)) + } else { + validate(defaultAddress) + } + return + } + + validate(userDefinedAddress) + } + + private func validate(_ aliasLocalPart: String) { + let fullAddress = aliasLocalPart.fullLocalAlias(with: session) + + currentOperation = MXRoomAliasAvailabilityChecker.validate(aliasLocalPart: aliasLocalPart, with: session) { [weak self] result in + guard let self = self else { return } + + 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)) + } + } + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Service/Mock/MockSpaceCreationSettingsScreenState.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Service/Mock/MockSpaceCreationSettingsScreenState.swift new file mode 100644 index 000000000..7a4ec66be --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Service/Mock/MockSpaceCreationSettingsScreenState.swift @@ -0,0 +1,67 @@ +// File created from TemplateAdvancedRoomsExample +// $ createSwiftUITwoScreen.sh Spaces/SpaceCreation SpaceCreation SpaceCreationMenu SpaceCreationSettings +// +// 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 MockSpaceCreationSettingsScreenState: MockScreenState, CaseIterable { + // A case for each state you want to represent + // with specific, minimal associated data that will allow you + // mock that screen. + case privateSpace + case validated + case validationFailed + + /// The associated screen + var screenType: Any.Type { + SpaceCreationSettings.self + } + + /// Generate the view struct for the screen state. + var screenView: ([Any], AnyView) { + let creationParameters = SpaceCreationParameters() + creationParameters.name = "Fake" + + let service: MockSpaceCreationSettingsService = MockSpaceCreationSettingsService() + switch self { + case .privateSpace: + creationParameters.isPublic = false + case .validated: + creationParameters.isPublic = true + service.simulateUpdate(addressValidationStatus: .valid("#fake:fake-domain.org")) + case .validationFailed: + creationParameters.isPublic = true + creationParameters.topic = "Some short description" + creationParameters.userDefinedAddress = "fake-uri" + service.simulateUpdate(addressValidationStatus: .alreadyExists("#fake-uri:fake-domain.org")) + creationParameters.userSelectedAvatar = Asset.Images.appSymbol.image + } + + let viewModel = SpaceCreationSettingsViewModel(spaceCreationSettingsService: service, creationParameters: creationParameters) + + return ( + [service, viewModel], + AnyView(SpaceCreationSettings(viewModel: viewModel.context) + .addDependency(MockAvatarService.example)) + ) + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Service/Mock/MockSpaceCreationSettingsService.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Service/Mock/MockSpaceCreationSettingsService.swift new file mode 100644 index 000000000..0590f95ca --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Service/Mock/MockSpaceCreationSettingsService.swift @@ -0,0 +1,46 @@ +// File created from TemplateAdvancedRoomsExample +// $ createSwiftUITwoScreen.sh Spaces/SpaceCreation SpaceCreation SpaceCreationMenu SpaceCreationSettings +// +// 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 MockSpaceCreationSettingsService: SpaceCreationSettingsServiceProtocol { + + + var addressValidationSubject: CurrentValueSubject + var avatarViewDataSubject: CurrentValueSubject + var defaultAddressSubject: CurrentValueSubject + var spaceAddress: String? + var roomName: String + var userDefinedAddress: String? + var isAddressValid: Bool = true + + init() { + roomName = "Fake" + defaultAddressSubject = CurrentValueSubject("fake-uri") + addressValidationSubject = CurrentValueSubject(.none("#fake-uri:fake-domain.org")) + avatarViewDataSubject = CurrentValueSubject(AvatarInput(mxContentUri: defaultAddressSubject.value, matrixItemId: "", displayName: roomName)) + } + + func simulateUpdate(addressValidationStatus: SpaceCreationSettingsAddressValidationStatus) { + self.addressValidationSubject.value = addressValidationStatus + } + +// func simulateUpdate() +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Service/SpaceCreationSettingsServiceProtocol.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Service/SpaceCreationSettingsServiceProtocol.swift new file mode 100644 index 000000000..bbc47c10d --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Service/SpaceCreationSettingsServiceProtocol.swift @@ -0,0 +1,30 @@ +// File created from TemplateAdvancedRoomsExample +// $ createSwiftUITwoScreen.sh Spaces/SpaceCreation SpaceCreation SpaceCreationMenu SpaceCreationSettings +// +// 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, *) +protocol SpaceCreationSettingsServiceProtocol: AnyObject { + var defaultAddressSubject: CurrentValueSubject { get } + var addressValidationSubject: CurrentValueSubject { get } + var avatarViewDataSubject: CurrentValueSubject { get } + var roomName: String { get set } + var userDefinedAddress: String? { get set } + var isAddressValid: Bool { get } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Test/UI/SpaceCreationSettingsUITests.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Test/UI/SpaceCreationSettingsUITests.swift new file mode 100644 index 000000000..66e5cc3bd --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Test/UI/SpaceCreationSettingsUITests.swift @@ -0,0 +1,51 @@ +// File created from TemplateAdvancedRoomsExample +// $ createSwiftUITwoScreen.sh Spaces/SpaceCreation SpaceCreation SpaceCreationMenu SpaceCreationSettings +// +// 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 SpaceCreationSettingsUITests: MockScreenTest { + + override class var screenType: MockScreenState.Type { + return MockSpaceCreationSettingsScreenState.self + } + + override class func createTest() -> MockScreenTest { + return SpaceCreationSettingsUITests(selector: #selector(verifySpaceCreationSettingsScreen)) + } + + func verifySpaceCreationSettingsScreen() throws { + guard let screenState = screenState as? MockSpaceCreationSettingsScreenState else { fatalError("no screen") } + switch screenState { + case .privateSpace: break + case .validated: break + case .validationFailed: break + } + } + + func verifyPrivateSpace() { + let addressTextField = app.groups["addressTextField"] + XCTAssertEqual(addressTextField.exists, false) + } + + func verifyPublicValidated() { + let addressTextField = app.groups["addressTextField"] + XCTAssertEqual(addressTextField.exists, true) + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Test/Unit/SpaceCreationSettingsViewModelTests.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Test/Unit/SpaceCreationSettingsViewModelTests.swift new file mode 100644 index 000000000..e95f0f87d --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Test/Unit/SpaceCreationSettingsViewModelTests.swift @@ -0,0 +1,66 @@ +// File created from TemplateAdvancedRoomsExample +// $ createSwiftUITwoScreen.sh Spaces/SpaceCreation SpaceCreation SpaceCreationMenu SpaceCreationSettings +// +// 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 SpaceCreationSettingsViewModelTests: XCTestCase { + + let creationParameters = SpaceCreationParameters() + var service: MockSpaceCreationSettingsService! + var viewModel: SpaceCreationSettingsViewModel! + var context: SpaceCreationSettingsViewModel.Context! + var cancellables = Set() + + + override func setUpWithError() throws { + creationParameters.name = "Fake" + creationParameters.isPublic = true + creationParameters.topic = "Some short description" + creationParameters.userSelectedAvatar = Asset.Images.appSymbol.image + + service = MockSpaceCreationSettingsService() + viewModel = SpaceCreationSettingsViewModel(spaceCreationSettingsService: service, creationParameters: creationParameters) + context = viewModel.context + } + + func testInitialState() { + XCTAssertEqual(context.viewState.title, creationParameters.isPublic ? VectorL10n.spacesCreationPublicSpaceTitle : VectorL10n.spacesCreationPrivateSpaceTitle) + XCTAssertEqual(context.viewState.isAddressValid, true) + XCTAssertEqual(context.viewState.defaultAddress, "#fake:matrix.org") + XCTAssertEqual(context.viewState.addressMessage, VectorL10n.spacesCreationAddressDefaultMessage("#fake:matrix.org")) + XCTAssertEqual(context.viewState.avatarImage, Asset.Images.appSymbol.image) + XCTAssertEqual(context.roomName, creationParameters.name) + XCTAssertEqual(context.topic, creationParameters.topic) + } + + 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/SpaceCreation/SpaceCreationSettings/View/SpaceCreationSettings.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/View/SpaceCreationSettings.swift new file mode 100644 index 000000000..ff88cd630 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/View/SpaceCreationSettings.swift @@ -0,0 +1,164 @@ +// File created from TemplateAdvancedRoomsExample +// $ createSwiftUITwoScreen.sh Spaces/SpaceCreation SpaceCreation SpaceCreationMenu SpaceCreationSettings +// +// 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.0, *) +struct SpaceCreationSettings: View { + + // MARK: - Properties + + @ObservedObject var viewModel: SpaceCreationSettingsViewModel.Context + + // MARK: Private + + @Environment(\.theme) private var theme: ThemeSwiftUI + + // MARK: Public + + @ViewBuilder + var body: some View { + VStack(alignment: .center) { + headerView + formView + footerView + } + .padding(EdgeInsets(top: 24, leading: 16, bottom: 24, trailing: 16)) + .background(theme.colors.background) + .navigationTitle(viewModel.viewState.title) + .configureNavigationBar{ + $0.navigationBar.shadowImage = UIImage() + $0.navigationBar.barTintColor = UIColor(theme.colors.background) + $0.navigationBar.tintColor = UIColor(theme.colors.secondaryContent) + } + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button(action: { + viewModel.send(viewAction: .cancel) + }) { + Image(uiImage: Asset.Images.spacesModalClose.image).renderingMode(.template) + } + } + } + } + + // MARK: - Private + + @ViewBuilder + private var headerView: some View { + VStack(alignment: .center, spacing: nil) { + Text(VectorL10n.spacesCreationSettingsMessage).multilineTextAlignment(.center) + Spacer().frame(height: 22) + } + } + + @ViewBuilder + private var avatarView: some View { + ZStack(alignment: .bottomTrailing) { + ZStack { + GeometryReader { reader in + SpaceAvatarImage(mxContentUri: viewModel.viewState.avatar.mxContentUri, matrixItemId: viewModel.viewState.avatar.matrixItemId, displayName: viewModel.viewState.avatar.displayName, size: .xxLarge) + .gesture(TapGesture().onEnded { _ in + viewModel.send(viewAction: .pickImage(reader.frame(in: .global))) + }) + } + .padding(6) + if let image = viewModel.viewState.avatarImage { + Image(uiImage: image) + .resizable() + .frame(width: 80, height: 80, alignment: .center) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .aspectRatio(contentMode: .fill) + } + }.padding(10) + Image(systemName: "camera.fill") + .renderingMode(.template) + .foregroundColor(theme.colors.secondaryContent) + .frame(width: 32, height: 32, alignment: .center) + .background(theme.colors.background) + .clipShape(Circle()) + }.frame(width: 112, height: 112) + } + + @ViewBuilder + private var formView: some View { + GeometryReader { geometryReader in + ScrollView { + ScrollViewReader { value in + VStack { + avatarView + Spacer() + VStack(alignment: .leading, spacing: 20) { + RoundedBorderTextField(title: VectorL10n.createRoomPlaceholderName, placeHolder: "", text: $viewModel.roomName, footerText: .constant(viewModel.viewState.roomNameError), isError: .constant(true), configuration: UIKitTextInputConfiguration( returnKeyType: .next)) { newText in + viewModel.send(viewAction: .nameChanged(newText)) + } + RoundedBorderTextEditor(title: nil, placeHolder: VectorL10n.roomDetailsTopic, text: $viewModel.topic, textMaxHeight: 72, error: .constant(nil), onTextChanged: { + newText in + viewModel.send(viewAction: .topicChanged(newText)) + }, onEditingChanged: { editing in + if editing { + value.scrollTo("topicTextEditor", anchor: .center) + } + }) + .id("topicTextEditor") + 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)) + }, onEditingChanged: { editing in + if editing { + value.scrollTo("addressTextField", anchor: .bottom) + } + }) + .id("addressTextField") + .accessibility(identifier: "addressTextField") + } + } + .padding(EdgeInsets(top: 0, leading: 2, bottom: 3, trailing: 2)) + } + .animation(.easeOut(duration: 0.2)) + .frame(minHeight: geometryReader.size.height - 2) + } + } + } + } + + @ViewBuilder + private var footerView: some View { + ThemableButton(icon: nil, title: VectorL10n.next) { + ResponderManager.resignFirstResponder() + viewModel.send(viewAction: .done) + } + } +} + +// MARK: - Previews + +@available(iOS 14.0, *) +struct SpaceCreationSettings_Previews: PreviewProvider { + static let stateRenderer = MockSpaceCreationSettingsScreenState.stateRenderer + static var previews: some View { + Group { + stateRenderer.screenGroup(addNavigation: true) + .theme(.light).preferredColorScheme(.light) + stateRenderer.screenGroup(addNavigation: true) + .theme(.dark).preferredColorScheme(.dark) + } + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/ViewModel/SpaceCreationSettingsViewModel.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/ViewModel/SpaceCreationSettingsViewModel.swift new file mode 100644 index 000000000..47c9fe1f6 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/ViewModel/SpaceCreationSettingsViewModel.swift @@ -0,0 +1,179 @@ +// File created from TemplateAdvancedRoomsExample +// $ createSwiftUITwoScreen.sh Spaces/SpaceCreation SpaceCreation SpaceCreationMenu SpaceCreationSettings +// +// 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 SpaceCreationSettingsViewModelType = StateStoreViewModel + +@available(iOS 14, *) +class SpaceCreationSettingsViewModel: SpaceCreationSettingsViewModelType, SpaceCreationSettingsViewModelProtocol { + + // MARK: - Properties + + // MARK: Private + + private let spaceCreationSettingsService: SpaceCreationSettingsServiceProtocol + private let creationParameters: SpaceCreationParameters + + // MARK: Public + + var callback: ((SpaceCreationSettingsViewModelAction) -> Void)? + + // MARK: - Setup + + init(spaceCreationSettingsService: SpaceCreationSettingsServiceProtocol, creationParameters: SpaceCreationParameters) { + self.spaceCreationSettingsService = spaceCreationSettingsService + self.creationParameters = creationParameters + let defaultState = Self.defaultState(creationParameters: creationParameters, validationStatus: spaceCreationSettingsService.addressValidationSubject.value) + super.init(initialViewState: defaultState) + setupServiceObserving() + } + + private func setupServiceObserving() { + let defaultAddressUpdatePublisher = spaceCreationSettingsService.defaultAddressSubject + .map(SpaceCreationSettingsStateAction.updateRoomDefaultAddress) + .eraseToAnyPublisher() + dispatch(actionPublisher: defaultAddressUpdatePublisher) + + let addressValidationUpdatePublisher = spaceCreationSettingsService.addressValidationSubject + .map(SpaceCreationSettingsStateAction.updateAddressValidationStatus) + .eraseToAnyPublisher() + dispatch(actionPublisher: addressValidationUpdatePublisher) + + let avatarUpdatePublisher = spaceCreationSettingsService.avatarViewDataSubject + .map(SpaceCreationSettingsStateAction.updateAvatar) + .eraseToAnyPublisher() + dispatch(actionPublisher: avatarUpdatePublisher) + } + + private static func defaultState(creationParameters: SpaceCreationParameters, validationStatus: SpaceCreationSettingsAddressValidationStatus) -> SpaceCreationSettingsViewState { + let bindings = SpaceCreationSettingsViewModelBindings( + roomName: creationParameters.name ?? "", + topic: creationParameters.topic ?? "", + address: creationParameters.userDefinedAddress ?? "") + + return SpaceCreationSettingsViewState( + title: creationParameters.isPublic ? VectorL10n.spacesCreationPublicSpaceTitle : VectorL10n.spacesCreationPrivateSpaceTitle, + showRoomAddress: creationParameters.showAddress, + defaultAddress: creationParameters.address ?? "", + roomNameError: nil, + addressMessage: addressMessage(with: validationStatus), + isAddressValid: isAddressValid(with: validationStatus), + avatar: AvatarInput(mxContentUri: nil, matrixItemId: "", displayName: nil), + bindings: bindings) + } + + // MARK: - Public + + func updateAvatarImage(with image: UIImage?) { + creationParameters.userSelectedAvatar = image + dispatch(action: .updateAvatarImage(image)) + } + + override func process(viewAction: SpaceCreationSettingsViewAction) { + switch viewAction { + case .done: + done() + case .cancel: + cancel() + case .pickImage(let sourceRect): + pickImage(from: sourceRect) + case .nameChanged(let newValue): + spaceCreationSettingsService.roomName = newValue + creationParameters.address = spaceCreationSettingsService.defaultAddressSubject.value + creationParameters.name = newValue + dispatch(action: .updateRoomNameError(newValue.isEmpty ? VectorL10n.spacesCreationEmptyRoomNameError : nil)) + case .addressChanged(let newValue): + spaceCreationSettingsService.userDefinedAddress = newValue + creationParameters.userDefinedAddress = newValue + case .topicChanged(let newValue): + creationParameters.topic = newValue + } + } + + override class func reducer(state: inout SpaceCreationSettingsViewState, action: SpaceCreationSettingsStateAction) { + switch action { + case .updateRoomNameError(let error): + state.roomNameError = error + case .updateRoomDefaultAddress(let defaultAddress): + state.defaultAddress = defaultAddress + case .updateAddressValidationStatus(let validationStatus): + state.addressMessage = Self.addressMessage(with: validationStatus) + state.isAddressValid = Self.isAddressValid(with: validationStatus) + case .updateAvatar(let avatar): + state.avatar = avatar + case .updateAvatarImage(let image): + state.avatarImage = image + } + } + + // MARK: - Private + + private func done() { + guard !context.roomName.isEmpty else { + dispatch(action: .updateRoomNameError(VectorL10n.spacesCreationEmptyRoomNameError)) + return + } + + guard !creationParameters.isPublic || spaceCreationSettingsService.isAddressValid else { + return + } + + creationParameters.name = context.roomName + creationParameters.topic = context.topic + creationParameters.userDefinedAddress = context.address + creationParameters.address = spaceCreationSettingsService.defaultAddressSubject.value + + dispatch(action: .updateRoomNameError(nil)) + callback?(.done) + } + + private func cancel() { + callback?(.cancel) + } + + 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/SpaceCreation/SpaceCreationSettings/ViewModel/SpaceCreationSettingsViewModelProtocol.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/ViewModel/SpaceCreationSettingsViewModelProtocol.swift new file mode 100644 index 000000000..9802bca02 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/ViewModel/SpaceCreationSettingsViewModelProtocol.swift @@ -0,0 +1,25 @@ +// File created from TemplateAdvancedRoomsExample +// $ createSwiftUITwoScreen.sh Spaces/SpaceCreation SpaceCreation SpaceCreationMenu SpaceCreationSettings +// +// 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 UIKit + +protocol SpaceCreationSettingsViewModelProtocol { + var callback: ((SpaceCreationSettingsViewModelAction) -> Void)? { get set } + func updateAvatarImage(with image: UIImage?) +} From 6e1f8078adfe24cc9d753e74dba1cfff6922f512 Mon Sep 17 00:00:00 2001 From: Gil Eluard Date: Tue, 23 Nov 2021 09:39:17 +0100 Subject: [PATCH 002/165] [iOS] Create public space #143 - Reverted LegacyAppDelegate --- Riot/Modules/Application/LegacyAppDelegate.m | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/Riot/Modules/Application/LegacyAppDelegate.m b/Riot/Modules/Application/LegacyAppDelegate.m index 986181173..75060f84b 100644 --- a/Riot/Modules/Application/LegacyAppDelegate.m +++ b/Riot/Modules/Application/LegacyAppDelegate.m @@ -190,8 +190,6 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni The launch animation container view */ UIView *launchAnimationContainerView; - - id graphUpdateObserver; } @property (strong, nonatomic) UIAlertController *mxInAppNotification; @@ -4522,10 +4520,10 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni else { MXWeakify(self); - graphUpdateObserver = [[NSNotificationCenter defaultCenter] addObserverForName:MXSpaceService.didBuildSpaceGraph object:nil queue:nil usingBlock:^(NSNotification * _Nonnull note) { + __block __weak id observer = [[NSNotificationCenter defaultCenter] addObserverForName:MXSpaceService.didBuildSpaceGraph object:nil queue:nil usingBlock:^(NSNotification * _Nonnull note) { MXStrongifyAndReturnIfNil(self); - [[NSNotificationCenter defaultCenter] removeObserver:graphUpdateObserver]; + [[NSNotificationCenter defaultCenter] removeObserver:observer]; if ([session.spaceService getSpaceWithId:spaceId]) { [self restoreInitialDisplay:^{ From 1473636c467a31ff64fc042534078567bfaca681 Mon Sep 17 00:00:00 2001 From: Gil Eluard Date: Wed, 1 Dec 2021 23:56:59 +0100 Subject: [PATCH 003/165] [iOS] Create public space #143 - Update after design review --- .../Contents.json | 23 ++++ .../space_creation_camera.png | Bin 0 -> 391 bytes .../space_creation_camera@2x.png | Bin 0 -> 525 bytes .../space_creation_camera@3x.png | Bin 0 -> 760 bytes .../Contents.json | 23 ++++ .../space_creation_private.png | Bin 0 -> 384 bytes .../space_creation_private@2x.png | Bin 0 -> 597 bytes .../space_creation_private@3x.png | Bin 0 -> 865 bytes .../Contents.json | 23 ++++ .../space_creation_public.png | Bin 0 -> 733 bytes .../space_creation_public@2x.png | Bin 0 -> 1320 bytes .../space_creation_public@3x.png | Bin 0 -> 1786 bytes .../space_home_icon.png | Bin 328 -> 0 bytes .../space_home_icon@2x.png | Bin 541 -> 0 bytes .../space_home_icon@3x.png | Bin 703 -> 0 bytes .../Contents.json | 6 +- .../space_home_icon_dark.png | Bin 0 -> 244 bytes .../space_home_icon_dark@2x.png | Bin 0 -> 389 bytes .../space_home_icon_dark@3x.png | Bin 0 -> 515 bytes .../Contents.json | 23 ++++ .../space_home_icon_light.png | Bin 0 -> 358 bytes .../space_home_icon_light@2x.png | Bin 0 -> 540 bytes .../space_home_icon_light@3x.png | Bin 0 -> 685 bytes .../spaces_add_space.png | Bin 166 -> 0 bytes .../spaces_add_space@2x.png | Bin 207 -> 0 bytes .../spaces_add_space@3x.png | Bin 246 -> 0 bytes .../Contents.json | 23 ++++ .../spaces_add_space_dark.png | Bin 0 -> 172 bytes .../spaces_add_space_dark@2x.png | Bin 0 -> 206 bytes .../spaces_add_space_dark@3x.png | Bin 0 -> 247 bytes .../Contents.json | 23 ++++ .../spaces_add_space_light.png | Bin 0 -> 179 bytes .../spaces_add_space_light@2x.png | Bin 0 -> 227 bytes .../spaces_add_space_light@3x.png | Bin 0 -> 274 bytes .../Contents.json | 6 +- .../spaces_modal_back.png | Bin 0 -> 319 bytes .../spaces_modal_back@2x.png | Bin 0 -> 445 bytes .../spaces_modal_back@3x.png | Bin 0 -> 595 bytes Riot/Assets/en.lproj/Vector.strings | 22 +++- Riot/Generated/Images.swift | 10 +- Riot/Generated/Strings.swift | 50 +++++++- .../SwiftUI/VectorHostingController.swift | 13 ++ Riot/Modules/SideMenu/SideMenuViewModel.swift | 1 - .../Spaces/SpaceList/SpaceListViewModel.swift | 18 +-- .../SpaceMenu/SpaceMenuViewController.swift | 8 +- .../Common/Avatar/View/SpaceAvatarImage.swift | 2 +- .../Util/NavigationBarConfigurator.swift | 71 ----------- .../Common/Util/NextViewModifier.swift | 2 +- .../Modules/Common/Util/OptionButton.swift | 4 +- .../Common/Util/RoundedBorderTextEditor.swift | 3 +- .../Common/Util/RoundedBorderTextField.swift | 5 +- .../Modules/Common/Util/ThemableButton.swift | 2 +- .../Common/Util/ThemableNavigationBar.swift | 88 +++++++++++++ .../Common/Util/ThemableTextField.swift | 6 +- .../Modules/Common/Util/WaitOverlay.swift | 85 +++++++++++++ .../SpaceCreationCoordinator.swift | 49 ++++++-- ...SpaceCreationEmailInvitesCoordinator.swift | 54 +++++++- ...reationEmailInvitesCoordinatorAction.swift | 1 + .../SpaceCreationEmailInvitesPresence.swift | 44 ------- ...SpaceCreationEmailInvitesStateAction.swift | 1 + .../SpaceCreationEmailInvitesViewAction.swift | 1 + ...eCreationEmailInvitesViewModelResult.swift | 3 + .../SpaceCreationEmailInvitesViewState.swift | 1 + .../SpaceCreationEmailInvitesService.swift | 25 ++++ ...SpaceCreationEmailInvitesScreenState.swift | 14 ++- ...MockSpaceCreationEmailInvitesService.swift | 13 +- ...eCreationEmailInvitesServiceProtocol.swift | 3 + ...ceCreationEmailInvitesViewModelTests.swift | 2 +- .../View/SpaceCreationEmailInvites.swift | 41 +++--- .../SpaceCreationEmailInvitesViewModel.swift | 34 ++++- ...CreationMatrixItemChooserCoordinator.swift | 4 +- ...onMatrixItemChooserCoordinatorAction.swift | 1 + ...rixItemListStateActionListViewAction.swift | 1 + ...emListStateActionListViewModelAction.swift | 1 + ...paceCreationMatrixItemChooserService.swift | 29 ++++- .../View/SpaceCreationMatrixItemChooser.swift | 58 +++++---- ...ceCreationMatrixItemChooserViewModel.swift | 6 + .../SpaceCreationMenuCoordinator.swift | 6 +- ...aceCreationMenuCoordinatorParamaters.swift | 1 + .../SpaceCreationMenuCoordinatorAction.swift | 1 + .../Model/SpaceCreationMenuViewAction.swift | 1 + .../SpaceCreationMenuViewModelAction.swift | 1 + .../Model/SpaceCreationParameters.swift | 68 ++++++++-- .../View/SpaceCreationMenu.swift | 62 +++++---- .../SpaceCreationMenuViewModel.swift | 6 + .../SpaceCreationPostProcessCoordinator.swift | 2 +- .../SpaceCreationPostProcessPresence.swift | 44 ------- .../Model/SpaceCreationPostProcessTask.swift | 10 +- .../SpaceCreationPostProcessViewState.swift | 2 + .../SpaceCreationPostProcessService.swift | 89 +++++++------ ...kSpaceCreationPostProcessScreenState.swift | 14 ++- .../MockSpaceCreationPostProcessService.swift | 50 ++++++-- ...ceCreationPostProcessServiceProtocol.swift | 2 + .../UI/SpaceCreationPostProcessUITests.swift | 17 +-- ...aceCreationPostProcessViewModelTests.swift | 43 +++---- .../View/SpaceCreationPostProcess.swift | 74 +++++++---- .../SpaceCreationPostProcessViewModel.swift | 2 + .../SpaceCreationRoomsCoordinator.swift | 5 +- .../SpaceCreationRoomsCoordinatorAction.swift | 1 + .../Model/SpaceCreationRoomsViewAction.swift | 1 + .../SpaceCreationRoomsViewModelResult.swift | 1 + .../View/SpaceCreationRooms.swift | 82 ++++++------ .../SpaceCreationRoomsViewModel.swift | 8 ++ .../SpaceCreationSettingsCoordinator.swift | 4 +- ...aceCreationSettingsCoordinatorAction.swift | 1 + .../SpaceCreationSettingsViewAction.swift | 1 + ...SpaceCreationSettingsViewModelAction.swift | 1 + .../View/SpaceCreationSettings.swift | 118 ++++++++++-------- .../SpaceCreationSettingsViewModel.swift | 7 ++ 109 files changed, 1122 insertions(+), 529 deletions(-) create mode 100644 Riot/Assets/Images.xcassets/Spaces/space_creation_camera.imageset/Contents.json create mode 100644 Riot/Assets/Images.xcassets/Spaces/space_creation_camera.imageset/space_creation_camera.png create mode 100644 Riot/Assets/Images.xcassets/Spaces/space_creation_camera.imageset/space_creation_camera@2x.png create mode 100644 Riot/Assets/Images.xcassets/Spaces/space_creation_camera.imageset/space_creation_camera@3x.png create mode 100644 Riot/Assets/Images.xcassets/Spaces/space_creation_private.imageset/Contents.json create mode 100644 Riot/Assets/Images.xcassets/Spaces/space_creation_private.imageset/space_creation_private.png create mode 100644 Riot/Assets/Images.xcassets/Spaces/space_creation_private.imageset/space_creation_private@2x.png create mode 100644 Riot/Assets/Images.xcassets/Spaces/space_creation_private.imageset/space_creation_private@3x.png create mode 100644 Riot/Assets/Images.xcassets/Spaces/space_creation_public.imageset/Contents.json create mode 100644 Riot/Assets/Images.xcassets/Spaces/space_creation_public.imageset/space_creation_public.png create mode 100644 Riot/Assets/Images.xcassets/Spaces/space_creation_public.imageset/space_creation_public@2x.png create mode 100644 Riot/Assets/Images.xcassets/Spaces/space_creation_public.imageset/space_creation_public@3x.png delete mode 100644 Riot/Assets/Images.xcassets/Spaces/space_home_icon.imageset/space_home_icon.png delete mode 100644 Riot/Assets/Images.xcassets/Spaces/space_home_icon.imageset/space_home_icon@2x.png delete mode 100644 Riot/Assets/Images.xcassets/Spaces/space_home_icon.imageset/space_home_icon@3x.png rename Riot/Assets/Images.xcassets/Spaces/{spaces_add_space.imageset => space_home_icon_dark.imageset}/Contents.json (64%) create mode 100644 Riot/Assets/Images.xcassets/Spaces/space_home_icon_dark.imageset/space_home_icon_dark.png create mode 100644 Riot/Assets/Images.xcassets/Spaces/space_home_icon_dark.imageset/space_home_icon_dark@2x.png create mode 100644 Riot/Assets/Images.xcassets/Spaces/space_home_icon_dark.imageset/space_home_icon_dark@3x.png create mode 100644 Riot/Assets/Images.xcassets/Spaces/space_home_icon_light.imageset/Contents.json create mode 100644 Riot/Assets/Images.xcassets/Spaces/space_home_icon_light.imageset/space_home_icon_light.png create mode 100644 Riot/Assets/Images.xcassets/Spaces/space_home_icon_light.imageset/space_home_icon_light@2x.png create mode 100644 Riot/Assets/Images.xcassets/Spaces/space_home_icon_light.imageset/space_home_icon_light@3x.png delete mode 100644 Riot/Assets/Images.xcassets/Spaces/spaces_add_space.imageset/spaces_add_space.png delete mode 100644 Riot/Assets/Images.xcassets/Spaces/spaces_add_space.imageset/spaces_add_space@2x.png delete mode 100644 Riot/Assets/Images.xcassets/Spaces/spaces_add_space.imageset/spaces_add_space@3x.png create mode 100644 Riot/Assets/Images.xcassets/Spaces/spaces_add_space_dark.imageset/Contents.json create mode 100644 Riot/Assets/Images.xcassets/Spaces/spaces_add_space_dark.imageset/spaces_add_space_dark.png create mode 100644 Riot/Assets/Images.xcassets/Spaces/spaces_add_space_dark.imageset/spaces_add_space_dark@2x.png create mode 100644 Riot/Assets/Images.xcassets/Spaces/spaces_add_space_dark.imageset/spaces_add_space_dark@3x.png create mode 100644 Riot/Assets/Images.xcassets/Spaces/spaces_add_space_light.imageset/Contents.json create mode 100644 Riot/Assets/Images.xcassets/Spaces/spaces_add_space_light.imageset/spaces_add_space_light.png create mode 100644 Riot/Assets/Images.xcassets/Spaces/spaces_add_space_light.imageset/spaces_add_space_light@2x.png create mode 100644 Riot/Assets/Images.xcassets/Spaces/spaces_add_space_light.imageset/spaces_add_space_light@3x.png rename Riot/Assets/Images.xcassets/Spaces/{space_home_icon.imageset => spaces_modal_back.imageset}/Contents.json (66%) create mode 100644 Riot/Assets/Images.xcassets/Spaces/spaces_modal_back.imageset/spaces_modal_back.png create mode 100644 Riot/Assets/Images.xcassets/Spaces/spaces_modal_back.imageset/spaces_modal_back@2x.png create mode 100644 Riot/Assets/Images.xcassets/Spaces/spaces_modal_back.imageset/spaces_modal_back@3x.png delete mode 100644 RiotSwiftUI/Modules/Common/Util/NavigationBarConfigurator.swift create mode 100644 RiotSwiftUI/Modules/Common/Util/ThemableNavigationBar.swift create mode 100644 RiotSwiftUI/Modules/Common/Util/WaitOverlay.swift delete mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Model/SpaceCreationEmailInvitesPresence.swift delete mode 100644 RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Model/SpaceCreationPostProcessPresence.swift diff --git a/Riot/Assets/Images.xcassets/Spaces/space_creation_camera.imageset/Contents.json b/Riot/Assets/Images.xcassets/Spaces/space_creation_camera.imageset/Contents.json new file mode 100644 index 000000000..eb54967ab --- /dev/null +++ b/Riot/Assets/Images.xcassets/Spaces/space_creation_camera.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "space_creation_camera.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "space_creation_camera@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "space_creation_camera@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Riot/Assets/Images.xcassets/Spaces/space_creation_camera.imageset/space_creation_camera.png b/Riot/Assets/Images.xcassets/Spaces/space_creation_camera.imageset/space_creation_camera.png new file mode 100644 index 0000000000000000000000000000000000000000..65264a0384eef498b68e68eac3f5aceff1382898 GIT binary patch literal 391 zcmV;20eJq2P)Ld*I;8S`{9?5`EkS9ehmhHv0OwOAbI z_t)l+NPS3!2WJ+z*WPvl?c5DC3_P!A1XwOV=vI+Zi3IiUlNh?ek#LhQCIcNV-&Mzr zC9-|CmJE`O0rAvLgPvD}q3UPT%c5Nt=c8G{h%YWKc{Iw$$&WyTw6Pb>79v=>si!Ho#wY0IVXq#9Wsm>>002ovPDHLkV1oI*p>F^H literal 0 HcmV?d00001 diff --git a/Riot/Assets/Images.xcassets/Spaces/space_creation_camera.imageset/space_creation_camera@2x.png b/Riot/Assets/Images.xcassets/Spaces/space_creation_camera.imageset/space_creation_camera@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..f87bc29e8eff443ce26a05ae0fcf5979464c51f3 GIT binary patch literal 525 zcmV+o0`mQdP)M-hd-?;Yy)nhc*N;v@=7o zF8Hk`eILyGGxJ^x?8z8{BDLnzDZoncDu4WXe?3H+4meKRGV}QnClqD$#4(wD7?pni z!i&=}NWzW_E!oWlLd%Y3PU8PU67o@nBE;!DNh9Szz46itVzQx*Rl5_v-OTa~h#IrQ z!tW?^I3b@WrZOHH@;Syoo-ppsXJ33a*R((^! z#8yyn$GIz398@HYK(rhPHk&f;pY^(RclCMLC5@JfBhr0fb#veNtn|89?z&C1Uttp( zNTTcjFz@u+x6@5lVH4TGE*ub%$qbn&J02uZ%z*!{Aa-R$8YM^x?cp?R_BgZ< z4Tn#nby;ViZ8-}Tk=oH3I{ce!DAl!g<^TupAS0IVgmNDoY2%NvCu_a|?(=|N@KZ?L P00000NkvXXu0mjf4*c8E literal 0 HcmV?d00001 diff --git a/Riot/Assets/Images.xcassets/Spaces/space_creation_camera.imageset/space_creation_camera@3x.png b/Riot/Assets/Images.xcassets/Spaces/space_creation_camera.imageset/space_creation_camera@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..c593c5bf51f252a11eedcce35bfaaddc57be4b95 GIT binary patch literal 760 zcmV;w5X`W#5qBe6Cid77DQdZvgHK$ z2tf*)ruJhdOmO0 z!9k31>+R-~HdtUS!hO1(mzgV{0Vof~U>FS!i^1z>As8UQ5_ zSITiR@A!P-BFf3L@xJ?fXKX8W-77#!P7No)M6vS&_0V3W23s^*`RM;|sM5{8FYdhskYGVyY$Arx%Olo7@ zIYf}_Ut%n2Zss`W-b8Vt(+YDpeh~f_Pe;x#Z*V9`E7OpNkC?2S|P*MVnT9$ue5+X)WNg|dYy8y~MXHRu#V{P7D6&(LntC^44+0000r1EZ z*~>0YUn-+eeb;Pq|HS<>tn-ZReRDp9zp80AI8fO-KTp;8N`AJ-b1BWso78TzHo5b} z-EeuZX>$HH9{V2|*Z=R;m}@?_F>~+qsaY$|o_HC$IjP=0IEOoF&6<17AA}zndmV2O z`*q9b-fEdHZL6(o5?%{??(los!}#Xvmk(Q%SxoM&-+S$W+MT-(R9shQ7?n@|*}Zg` zQQRYoP0Q64)YggfO9vYXy!*IHFmi^>Z>HwkpNuLb?;KBPYnkwC_G2E7gZUweHm{_< c%G$_!D;)Jt{~eQI2n;X=Pgg&ebxsLQ0G_X#;Q#;t literal 0 HcmV?d00001 diff --git a/Riot/Assets/Images.xcassets/Spaces/space_creation_private.imageset/space_creation_private@2x.png b/Riot/Assets/Images.xcassets/Spaces/space_creation_private.imageset/space_creation_private@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..581625eaf9fbd578933f38f6bf0fe912f2510ab0 GIT binary patch literal 597 zcmV-b0;>IqP)bz319>02uwhVR78;wMTifzh^7<32DB5D4cZOp2zhSm7^f)`B%zL- z5Lc>yPmvwl_mA(+CkMb_*liG0gxBpkI3mZ0u_HdM1Hc-or2^hILx5(>a540sI&-Li z(gM88$9-b&4Y+Y9QUd|K(Qt8FR!AuU-c`>bgiNwuyYTXJOlhrkYpxP5Woi(lo&zs~;`Ml=jtMM`a_?Skr>zgSO1Jdzhn8; jJ0%M@UKc`>wyS+A+)D ziKEis_4&>AaqAfqB-#>Z>{42gbjifJE6&7U@z`XJg}qK&7(QfFZ;9YIva@fd;)>lh zKVOHP%3%}@nz`W1)!$*4EM6!cxOv&BOJP}7sr@ARxbq8{nz(PJoH_k0;K__8cNUJF zac;_*=bO_)0vy${A00awKG#r}qiM$LoL6mUmT@pm+$XvH@F~Lw&sbt!+;4JVIl17& z%+KZzjWj*jLbZ>7nBbXdb#vjz`+en(e;+>g+*%}eFznKq_6L&dL*FdSGTkT6<@TeE zY5$XtzrxaPuXdZ&dwU|&-F-)#0|RzNt?jD~+sb0I=yFX)-<>|WKTm(YxO|)2lcnhl zoAKYYxXb^Z9uFyhtLD7ss)nCbn#IFhkB7R=mvV|kw7(vfQx($+yKB!L_sG2D{0gPJ z`yy3!uNc{VGqwA4jmU9fBuKr4o-}QVm z8%K3y%m+)pP2coB-90dS!SXg%*VB)jHe_%7&no6}pot?wfRS4%!I4F$qk&a$%D!3m z@2s`R`SzuDb>F`mjkYfFMQ4g8-j9jrT^2AYsA<*w;(Jp+6<=GrDq`;wiBsnzt+`*t z_nm*G`dl!_wLSa!Ot*B$i5*n|!7N$1Or!n{^f@<@m2+X{opcZ83NJL&cjmTKFdDwE!C~KF@$7%y%gbdF)!U<(`sfu9S zB3qKvKP&DQ_ThSbHBQjSH)>6%MZv`4c*f9w-RsszaX(vp%yIA0SGIVz`(VuNb*cxc z6&xOYDg#rQD|m*#+dppHzzW=qzRaHQ?dQnc(YAMTWH=*YyWgz~ zeNXm+F*gNCyA*YXiPNoZOH-36rb;pmu6RFny7e;k%XTC_!(U6Ahom^p3PxR;F_Si? z(KQYe1ioPoK)rV=LrHHS9o+j<3_{cJ(+6Kb;0lr@LIu)c8C%|OsiNwKUMb40_mCG+ zUSw3zhZz#dIjjT1z;2!vq$x)R{izD*?Gs~rWkc4Qr-J&R1=$$U%pL_JgA$_z6SFC` z3@1RG0I>{Nc;ZD^ zM(o%j_EaGXvoV@}ydK$D_H@tm{1hzkS7mFer+?np{r>t5zy&VwX@fyQaCiTzaRyZe zY=x$+Qd1cK%g4)17~&Wij1lpP1n|9EJ3bVETm)RVRpysUTMWTH71ny`G5zy{w?}R7 z&tJxn5t#^tf`3_RGKnq}0yfA>L_kW^Z+5r%;%7rri5e7#Di_Y$)s^pGJwEtnlxAS6 z6S&^&-Lcwr1Rp>?LDKzj5yr02Gua1Ll7^BEdEHKZ8!TZ(K&gx_WNXM*uM>{*_C^x$nUZr=d zF{Ga%5g8DXYo>nWt*W9Fs0~|9nlsS`l-khGntml1N$2ot|F;`YAq5^EJQ{xe?GMXD zq8ih!^3BSR<^K*I{T(+~tO{M4H-_yP%;REMz)ub*qXx!}nj~GjWmci|QXU8+W zHxh7v{-Yum7WYA8cuH-gI3nS*`)~4Q*Jz@FMb}C2S}dFFftyB=@J%pz4JPk@AmJ6t zPDwURFSP{J?vwBvra_WV2apjdg%3e$JD&RpZvI&2TvKz{F7Lho!#+MaD)*>4c=`qEcr*bGiO}TZDkhTObC(3-JmPfMI|_ zbvFP(yFsL2mOdlo0uNRDj16fBKm}vgiOo+Kj!AlduBCh$ZIctZBv4Sj5^XyN+O3!2 z!sclPy-s}-EJ5?--1ak=^g4~p^9?#Llw^n-^}XIb_&A8jW|fUb5t4vF9JNncjIn`? znDkP!Dfv}OA%hATry3&!?SAK{flcq&M9~Zz6=E5)OROk?=wzPR^bUz4tmEu*Z@C0S z`f3PZsynG>e8sk>y5RIX^<|0|n>Iv{;q&ut5dxI!{0ko#>NHe(o=5_tSwMsUN62{s z*`S<&A`#a|o{Pc?5dz0|+swDDJc!G3)#>O`5^Xk0HJ`hX1G=QkYMe^94WN>YuWQ#5 zm^x0Toc#oXu_j~y<9x&PE)Z|q+A*d9RQm9aYdG0}!Fu4x(4K5BKri9 z@lG5#q6K+ufr$qqSa_!Xlwh-Ksu?r&(H9U*yRTp*;I+0VTLM>CTF^ML>~QIFtV<;VHtlqFrGW zuAP8H0t#dg>txVH1-ROYn~f3PS5-f-K&tY3ZqIbGsH&+7=4(s!q&7A?|9&n=36%zu z$u(I)`TU$H6DDQ5`} z+e8@0pVcZofK%LW;NG}3JwZ=Z7&veu{R9o4ph^?Blu5Bn0__w#Q9nV`8@DDmW;jIU z0ymORP;+O7bdp|Tt(NDJf2ppm_~w0+ut$w5NIKz3sn z{h2`BpYel7El0R>I4agb8#X|@G;1o7kwDtw;3tW~Ec8k)$9Eq-nP=cU18q>Gedl@@ zE@(TMMglD@_x@C#RuZ1}SC>lIr{kE4luow;KC3H|1Df3J^w(9z!EmBmr8vae%DoK< z$=9F>U9&r#bTt1>Z<$X&J;@%c>`UUTecuki05ZFTkPGmK>OUxt zAhJ6QxY{Rx)5?}OV;o7rtUf4ehwepne`SHtD0fthvLu7(9t2QrZ!cn2TG$vd`V1d*z<@`m3;-sjIqFhJuD1Gq?)QI{k zOS4chMj?#`VxM2ha~^X?kTc~f`x!EkTVMglb?0q~lOF$aoa@Nu3WQGKSD;A3ppyJc z-QYdm%-il}ZSaHjCJ5ny1t__O7uHHZ>?Q(s!XNLQr?{T)uQ#)%S*Ad498)<4lu4wz z?~}kBh9VvYoxm~JS$x>1{18U`YzIp`FGZ3J>J*JP4n6#JWNa?h)wq**$7`p}s-KGNSMQy`4acfl&MB^>Rl#9K(D{%U((@Zr_6LZJtr z>Evb$bU~u?FnBO1EtJf@*X$`~SXMbN#zNur)lU1S{f`-B3M5<}!3CtZ+77fuI&k$z z5Y2Jw#()b5iKMZ6WSnwkAYa0JPEo2jSD@H+L$C-Y$gG4UI;G`gD&EZ7o-2^`X~y8T zjkwL3;-2EJ5gGT~V?hDb5eD{X7F88Yz*MeMu|X&pbETBn;&N{r_w>LHdsv&gS~ia~ zCJjS`C`|v3%H(H<7w@7JtkNjL1A1VGCN(zs!K0_|zF9Q%vv9<4P9;f!u7FYpta4J- z@+dlmX@(LrlGGMc6vAv^6yyTR08dn`%1y&u|@WFjhi;*cH>_~f(MEhllVU`eF*280yMJfJcDa&6`Q zFHjLy`ZsXO&0&#ny|&SG^c-5x!qd-I9}A}*>$Hv7Rt#rRm@)NyvB3C=L23#uE7nMq zm$CaMwE4)xnEJn(A_$sp{NYU6;zwOcvo%z)iQtplFb8?r;P`D~Zb%-)y!h{JE cCcGc~3$R4`3LgvNSO5S307*qoM6N<$f&*wmsQ>@~ literal 0 HcmV?d00001 diff --git a/Riot/Assets/Images.xcassets/Spaces/space_home_icon.imageset/space_home_icon.png b/Riot/Assets/Images.xcassets/Spaces/space_home_icon.imageset/space_home_icon.png deleted file mode 100644 index 75af27227b1b6188e19e8ac7d2bc87ec26357a14..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 328 zcmeAS@N?(olHy`uVBq!ia0vp^LLkh+1|-AI^@Rf|&H|6fVg?3oVGw3ym^DWND9BhG zhTZ3Td|VgYOkM^?pAVQ=P-_vWB1Z}%ws+8bxIb05VPUJZEi|C z;{_%k`KNX3RO}1YsXfUqSNDeccYADdk!Ve5VAweCyxPIvE-Ru|F6D5)P1$fCo?{U@zg;P#_9YMAGj(o_+1c))UIBTn|EuI W$BHw_GwXmJW$<+Mb6Mw<&;$U|jD0Nt diff --git a/Riot/Assets/Images.xcassets/Spaces/space_home_icon.imageset/space_home_icon@2x.png b/Riot/Assets/Images.xcassets/Spaces/space_home_icon.imageset/space_home_icon@2x.png deleted file mode 100644 index 7c4243805a7ee9f2eb41d18de09e0a5f7ad61b22..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 541 zcmV+&0^8&GMTB73VBc%(x2op^Z(>006G5*2R5MZ`K3K%eK~9_FE)5**lAJs6)dq98hU=2 zAoJz09QrHZ4BaMm1F(RA1!K>z#K;fh1n$sfx&EzbK>7R^VD8RY?RLKTY!5NC_mfxC)Fc*>ag);1BpJJQAJx9%ox+Z7B fZ64LSkYm9Yg}bR6n6zu$00000NkvXXu0mjfmv`En diff --git a/Riot/Assets/Images.xcassets/Spaces/space_home_icon.imageset/space_home_icon@3x.png b/Riot/Assets/Images.xcassets/Spaces/space_home_icon.imageset/space_home_icon@3x.png deleted file mode 100644 index 1076124e2355c20691557f578d8f86cc79853b74..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 703 zcmV;w0zmzVP)O+5pskHw5$+iRv|0W6F^RYI6-oQPFEoT$=G3lK

R+0QBnQv_WGPVH{i9|>U?0_j>yi`=?>mQVK*(>%wLtui6O?Z{j z)*lXvbpC5vNx{1PqPv1XPzc0~R~^bkb%T^4Gi@_|)`36-5&QIY@fwVj7O<3MlF@2v zY6E~S_VM)mosdu}fVQiam>OMct^s6F=h*b*d<3B0f)`}md+~G#NT7%v@v8lh39dip zunh_>rDO$0sBA5^PCoa1+60uu+-i6~MMlFyfn=zB4%1y`Z9$BdLoK4>6PDBW}W%iG$mJ*K75-{G)u-J5GD z=FnVnTVt^G*jf~tN1_K^fZmtOiA`L8a8d@x! z4=rh6c5uve785(RX=?Z(&v)m~zWe!#VIPyA>C)!a7j`U=xbt%7WG|6a2l;m)tCsM2 z#6-$Y-ZF*r@rT)FDlY^2w*9_SXsWQ>O8D-h_(ew62j)h&>^5B5-L+(o(k#bwk4$&e mh9vu&Z7Eqd>%LaSEK%36=kC`<{O$l9#o+1c=d#Wzp$Pz`16CgZ literal 0 HcmV?d00001 diff --git a/Riot/Assets/Images.xcassets/Spaces/space_home_icon_dark.imageset/space_home_icon_dark@2x.png b/Riot/Assets/Images.xcassets/Spaces/space_home_icon_dark.imageset/space_home_icon_dark@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..cf8c23942c239a61b9d88a4abe5952f09b784bbb GIT binary patch literal 389 zcmV;00eb$4P)H$W#KPGFqC5g5S{U<2IXd8CaZ6iSN)fBGetCYPi4()5lV zk>{TPuw=X7$*JxE<1GS<1EeY3l&;FyA!9T{RQNG2K@ zs}Z&H|6fVg?393lL^>oo1K-6l5$8 za(7}_cTVOdki(Mh=FB1gvIx4H{B)%Gl#tJ?E9(wW((MEUMbh4dK@ z!hUUb%`;dT9i@7>uy>gY--)H4uTJW#Dl7C!&@A*_)W4(BN{xB{i648Cyq3g2e|`Fg zY_OhhlKSP%#TyOTF4@WXx-RdNG-WYo58ER2xoDAXQHNctOuiGJ$kr!CNB%65Q9T=) zCtCC+Y>i;3qJPX&zTDXhyPnz{t}Nyb^_gI}CL;Ua!E_&OH6LkYw6eAK!n>}%9n~56 zGq_$JdmyfF^LVef!~ICj<<;RU<9QtP-kYA=_;24!yO{>@6ZDpJWuA3L7@(Ngtk!AC z$@$xHpG?hc#l-3-r#IYD`IOI~;`C^Rp-AVStBjo!45qr&F!anjQF+67y85lF>wn5@ pc|VcC-_&o5?c`t7{vtJ>7PHu}rKF7V7?_j%KY{7kn&r7$)&Ew6|o_dFiU*`Pgv}~jG z!fVt2$$gXd_gcx2QXTMoXHUM&p1@iAY7AbSpT^IzI@9=lpiFAzwBCM)xqja#OYGma z$9ZQ?+r32^aZi8NJdygnTF{eU)V1Y#?AaBb*V%V*O8Ptttp1!-Z)=K6XgP6I4F!rN5r zDe=lY3MLpIze^c_1q>`W5JJq5Nk~PL7_fkYA-;dqW`Ot9+?x~TP|T@YcSqr9^af=K z`TpSrO>&Q#TLr=?23xLMhcFyILqQ4)k<1mzoP#L{&0V+de1^lvV&NLTGYKCMun7yH z!^futb-6*{|1|)cpPN! zO$l)x_35+%DK)05EI}uf)((s>p==PZ^XgRY8X8F1i|Kbof$Gzn1b+`x)cAKh=`-nj eAYZyrVZj%%Oqu%c!dWi>0000jkc+3lY#xHMGXwGfua4;BRy}mq$AfjR{b;43_P#41F?qHbzTwi{I z5F+7GXr14-bV{HJT4Bu<*=G`=(pv0K^0|;e0U1ORvsEe{9Owy4T|iFCA(EJl#W?w1 zsqA{Da>!Hc@qqC)`x*^t6ds%8dxzK%o-|F&TGyBhi91%ESl}gg2>;$Zqw>fY^Q-E2 zDvNn;k9I`8#H=J1A^Dn8IWdFb5h?ka;s*F)q`6uM;GHoiwMo9tQS!`vH|Fer$GtN~ z86BgekoEV?FV^}NDITG|MXSednk3dYl33qJVtpft^^GLfH})V2;jKbBt5fMZ__@?IaSdVS)xTm})|!S3j3^ HP6hL xGQ5jLg6bCi!E<57&PyRooOL=C3=3N2ycvwUt^w_1@O1TaS?83{1OSTVL8AZw diff --git a/Riot/Assets/Images.xcassets/Spaces/spaces_add_space.imageset/spaces_add_space@3x.png b/Riot/Assets/Images.xcassets/Spaces/spaces_add_space.imageset/spaces_add_space@3x.png deleted file mode 100644 index a8d57e5603c2fccada1ad97703d65460eb2a2098..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 246 zcmeAS@N?(olHy`uVBq!ia0vp^Dj>|k1|%Oc%$NbBI14-?iy0UcEkKyjb(&!UP>``W z$lZxy-8q?;Kn_c~qpu?a!^VE@KZ&eBez&KKV@L(#+iM$nn;m#sAEv86UeI9VXcVM& z(!=18=$YgPRsu&S`y3Q~uN@mv|L{;7_pZDjKMo)NtE028SW|eCOmCpTVg)zHo+b%a zL$0J*+*Q}anLOXU@ZbC)@ph+9_1E{Tycc;5-^!N$ZJZdwFVXftG2;j8B88I$jr0Ck dYjhy#*4E0AJnhHla~J3=22WQ%mvv4FO#l(^Q@H>D diff --git a/Riot/Assets/Images.xcassets/Spaces/spaces_add_space_dark.imageset/Contents.json b/Riot/Assets/Images.xcassets/Spaces/spaces_add_space_dark.imageset/Contents.json new file mode 100644 index 000000000..f5861fe3f --- /dev/null +++ b/Riot/Assets/Images.xcassets/Spaces/spaces_add_space_dark.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "spaces_add_space_dark.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "spaces_add_space_dark@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "spaces_add_space_dark@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Riot/Assets/Images.xcassets/Spaces/spaces_add_space_dark.imageset/spaces_add_space_dark.png b/Riot/Assets/Images.xcassets/Spaces/spaces_add_space_dark.imageset/spaces_add_space_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..2e8b66fd09a28ab933c190396320d6e2193c54ca GIT binary patch literal 172 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`oCO|{#S9GG!XV7ZFl&wkP>``W z$lZxy-8q?;Kn_c~qpu?a!^VE@KZ&eBzN4p$V@L(#+ldD`85DS!h1H8#)yrAUyEuLc z{l3`l#qaey!`q`_^~5RBk=Bf}Yd$n2N6Zkd*1xPcvrog|qeT2dCY=lsmY7WWFF?Z> NJYD@<);T3K0RRylE`k66 literal 0 HcmV?d00001 diff --git a/Riot/Assets/Images.xcassets/Spaces/spaces_add_space_dark.imageset/spaces_add_space_dark@2x.png b/Riot/Assets/Images.xcassets/Spaces/spaces_add_space_dark.imageset/spaces_add_space_dark@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..dfc11fc8fa03ef58ccb2084f0a885a4522b37561 GIT binary patch literal 206 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=fdz&H|6fVg?2=RS;(M3{v?36l5$8 za(7}_cTVOdki(Mh=oo1K-6l5$8 za(7}_cTVOdki(Mh=j^!DSPwhZQi&~+qjezey@GQxV6r_Fyifg%WHMBb2s{BGq(TYN7AkAu=Xbd c!-ZW8b_=a`o(otd4RjZSr>mdKI;Vst06zj$fB*mh literal 0 HcmV?d00001 diff --git a/Riot/Assets/Images.xcassets/Spaces/spaces_add_space_light.imageset/Contents.json b/Riot/Assets/Images.xcassets/Spaces/spaces_add_space_light.imageset/Contents.json new file mode 100644 index 000000000..3003074fa --- /dev/null +++ b/Riot/Assets/Images.xcassets/Spaces/spaces_add_space_light.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "spaces_add_space_light.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "spaces_add_space_light@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "spaces_add_space_light@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Riot/Assets/Images.xcassets/Spaces/spaces_add_space_light.imageset/spaces_add_space_light.png b/Riot/Assets/Images.xcassets/Spaces/spaces_add_space_light.imageset/spaces_add_space_light.png new file mode 100644 index 0000000000000000000000000000000000000000..948a2b3bdcd39a425e9a155fb5f8d940379d44d4 GIT binary patch literal 179 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`oCO|{#S9GG!XV7ZFl&wkP>``W z$lZxy-8q?;Kn_c~qpu?a!^VE@KZ&eBzK5raV@L(#+lhf(3<3f!>37{NnkHK_6)l*` zZ$Ie>|EH|mYwvE$WV4W3*`sZ`OYp{%M)r{Qd66uamKs}Z*)6hnZpeX%XU8kJB_-K- Vopp{*k^!2@;OXk;vd$@?2>=daGy(ts literal 0 HcmV?d00001 diff --git a/Riot/Assets/Images.xcassets/Spaces/spaces_add_space_light.imageset/spaces_add_space_light@2x.png b/Riot/Assets/Images.xcassets/Spaces/spaces_add_space_light.imageset/spaces_add_space_light@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..55ce9ba2189e42748ed36ddd83e123f7af5a1f10 GIT binary patch literal 227 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=fdz&H|6fVg?2=RS;(M3{v?36l5$8 za(7}_cTVOdki(Mh=Ryk1J) R?k3O;44$rjF6*2UngGp%PR0NL literal 0 HcmV?d00001 diff --git a/Riot/Assets/Images.xcassets/Spaces/spaces_add_space_light.imageset/spaces_add_space_light@3x.png b/Riot/Assets/Images.xcassets/Spaces/spaces_add_space_light.imageset/spaces_add_space_light@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..1db00fbb16acbbb748b19691e39422875a6ea972 GIT binary patch literal 274 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA1|-9oezpTC&H|6fVg?393lL^>oo1K-6l5$8 za(7}_cTVOdki(Mh=++y6rH^M<-$3qxylE6 z8O)jPa1?+T+ut$wWy`@OE2IIg<>fi3PR_l#K_q8qQNsK|2tUB(jkPhU}=QOoP7kFx$z)EQlN?9-)<2LqxzVC=|IPSk8x+1z3Qx2V`Smzke9EuM zh2{<3joO``7T)xl!W7+kE#Bm7)y3yW4>RaGYAs~ZReZ5vQHTwr&*tLHJ0e%~fc|6f MboFyt=akR{098YIWdHyG literal 0 HcmV?d00001 diff --git a/Riot/Assets/Images.xcassets/Spaces/spaces_modal_back.imageset/spaces_modal_back@2x.png b/Riot/Assets/Images.xcassets/Spaces/spaces_modal_back.imageset/spaces_modal_back@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..d299195d28870b5c536f31fac9295beedebcb1be GIT binary patch literal 445 zcmV;u0Yd(XP)DoV|Cj=Kn?+4;a%~LfResnx?<8kt{}jH z*>8A!hbQeaPauS~z<=S1_kIB(Ytfv^pomCExxK!Kp2pO-o(q*y7bO>_JMP*p50Bwrp;4&{fufX(ff}~TZuuVgr*XMhdC`J zMvc=NN{l|xq%Og7q)b_Y?NFbp1nbRQiW02%LbGbxIPb2}9XswJg#BjjK#30sVJ|>_ nN(3L?5W?%OkTJ#>WB&RC-=5S~Jllv*6-El`San3q7Q#$nC_r5uMj4U~Eu<}8qeILt{PDRG$fK$7Ax ztAV7&Vb%gkjKi!1k{X8%4kS4a8x%--9M(I~Cd6UAmt6PCd_ltFuwG}-!kj%-k$*J3 zE+r0gvZAQA%m+`1UV(TVqm)`TzQIf-ZXaHjBtY*oNR7Tu%;)pH@eM*E4f>krYmqL@ zOp9736J`i_b$%LrzSY;@ABxf=S^29WIg+(Mt5PFbUG9(=$@+qsv`9{tTqQ+vwrDRU zlGCw|gh String { return VectorL10n.tr("Vector", "public_room_section_title", p1) @@ -4983,11 +4991,15 @@ public class VectorL10n: NSObject { public static var spaceTag: String { return VectorL10n.tr("Vector", "space_tag") } + /// description + public static var spaceTopic: String { + return VectorL10n.tr("Vector", "space_topic") + } /// Adding rooms coming soon public static var spacesAddRoomsComingSoonTitle: String { return VectorL10n.tr("Vector", "spaces_add_rooms_coming_soon_title") } - /// Add space + /// Create space public static var spacesAddSpaceTitle: String { return VectorL10n.tr("Vector", "spaces_add_space_title") } @@ -5027,6 +5039,14 @@ public class VectorL10n: NSObject { public static func spacesCreationAddressInvalidCharacters(_ p1: String) -> String { return VectorL10n.tr("Vector", "spaces_creation_address_invalid_characters", p1) } + /// You will need to start from scratch next time. + public static var spacesCreationCancelMessage: String { + return VectorL10n.tr("Vector", "spaces_creation_cancel_message") + } + /// Please confirm + public static var spacesCreationCancelTitle: String { + return VectorL10n.tr("Vector", "spaces_creation_cancel_title") + } /// Email public static var spacesCreationEmailInvitesEmailTitle: String { return VectorL10n.tr("Vector", "spaces_creation_email_invites_email_title") @@ -5039,7 +5059,7 @@ public class VectorL10n: NSObject { public static var spacesCreationEmailInvitesTitle: String { return VectorL10n.tr("Vector", "spaces_creation_email_invites_title") } - /// Name the room + /// Name required public static var spacesCreationEmptyRoomNameError: String { return VectorL10n.tr("Vector", "spaces_creation_empty_room_name_error") } @@ -5051,6 +5071,26 @@ public class VectorL10n: NSObject { public static var spacesCreationHint: String { return VectorL10n.tr("Vector", "spaces_creation_hint") } + /// in %@ spaces + public static func spacesCreationInManySpaces(_ p1: String) -> String { + return VectorL10n.tr("Vector", "spaces_creation_in_many_spaces", p1) + } + /// in 1 space + public static var spacesCreationInOneSpace: String { + return VectorL10n.tr("Vector", "spaces_creation_in_one_space") + } + /// in %@ + public static func spacesCreationInSpacename(_ p1: String) -> String { + return VectorL10n.tr("Vector", "spaces_creation_in_spacename", p1) + } + /// in %@ + %@ spaces + public static func spacesCreationInSpacenamePlusMany(_ p1: String, _ p2: String) -> String { + return VectorL10n.tr("Vector", "spaces_creation_in_spacename_plus_many", p1, p2) + } + /// in %@ + 1 space + public static func spacesCreationInSpacenamePlusOne(_ p1: String) -> String { + return VectorL10n.tr("Vector", "spaces_creation_in_spacename_plus_one", p1) + } /// Invite by username public static var spacesCreationInviteByUsername: String { return VectorL10n.tr("Vector", "spaces_creation_invite_by_username") @@ -5091,7 +5131,7 @@ public class VectorL10n: NSObject { public static func spacesCreationPostProcessAddingRooms(_ p1: String) -> String { return VectorL10n.tr("Vector", "spaces_creation_post_process_adding_rooms", p1) } - /// Creating room %@ + /// Creating %@ public static func spacesCreationPostProcessCreatingRoom(_ p1: String) -> String { return VectorL10n.tr("Vector", "spaces_creation_post_process_creating_room", p1) } @@ -5099,7 +5139,7 @@ public class VectorL10n: NSObject { public static var spacesCreationPostProcessCreatingSpace: String { return VectorL10n.tr("Vector", "spaces_creation_post_process_creating_space") } - /// Creating space %@ + /// Creating %@ public static func spacesCreationPostProcessCreatingSpaceTask(_ p1: String) -> String { return VectorL10n.tr("Vector", "spaces_creation_post_process_creating_space_task", p1) } @@ -5139,7 +5179,7 @@ public class VectorL10n: NSObject { public static var spacesCreationSharingTypeMeAndTeammatesTitle: String { return VectorL10n.tr("Vector", "spaces_creation_sharing_type_me_and_teammates_title") } - /// Make sure the right people have access to %@. You can change this later. + /// Make sure the right people have access %@. You can change this later. public static func spacesCreationSharingTypeMessage(_ p1: String) -> String { return VectorL10n.tr("Vector", "spaces_creation_sharing_type_message", p1) } diff --git a/Riot/Modules/Common/SwiftUI/VectorHostingController.swift b/Riot/Modules/Common/SwiftUI/VectorHostingController.swift index 4ba63ee6f..5d36aa97a 100644 --- a/Riot/Modules/Common/SwiftUI/VectorHostingController.swift +++ b/Riot/Modules/Common/SwiftUI/VectorHostingController.swift @@ -26,6 +26,7 @@ class VectorHostingController: UIHostingController { // MARK: Private + var isNavigationBarHidden: Bool = false var hidesBackTitleWhenPushed: Bool = false private var theme: Theme @@ -49,12 +50,24 @@ class VectorHostingController: UIHostingController { self.update(theme: self.theme) } + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + if isNavigationBarHidden { + self.navigationController?.isNavigationBarHidden = true + } + } + override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) if hidesBackTitleWhenPushed { vc_removeBackTitle() } + + if navigationController?.isNavigationBarHidden ?? false { + navigationController?.interactivePopGestureRecognizer?.delegate = nil + } } override func viewDidLayoutSubviews() { diff --git a/Riot/Modules/SideMenu/SideMenuViewModel.swift b/Riot/Modules/SideMenu/SideMenuViewModel.swift index c8baac060..b4b3059f1 100644 --- a/Riot/Modules/SideMenu/SideMenuViewModel.swift +++ b/Riot/Modules/SideMenu/SideMenuViewModel.swift @@ -100,7 +100,6 @@ final class SideMenuViewModel: SideMenuViewModelType { let sideMenuItems: [SideMenuItem] = [ .inviteFriends, .settings, - .help, .feedback ] diff --git a/Riot/Modules/Spaces/SpaceList/SpaceListViewModel.swift b/Riot/Modules/Spaces/SpaceList/SpaceListViewModel.swift index 9432ff20f..f1127657b 100644 --- a/Riot/Modules/Spaces/SpaceList/SpaceListViewModel.swift +++ b/Riot/Modules/Spaces/SpaceList/SpaceListViewModel.swift @@ -56,6 +56,8 @@ final class SpaceListViewModel: SpaceListViewModelType { NotificationCenter.default.addObserver(self, selector: #selector(self.sessionDidSync(notification:)), name: MXSpaceService.didBuildSpaceGraph, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(self.counterDidUpdateNotificationCount(notification:)), name: MXSpaceNotificationCounter.didUpdateNotificationCount, object: nil) + + NotificationCenter.default.addObserver(self, selector: #selector(self.loadData), name: NSNotification.Name.themeServiceDidChangeTheme, object: nil) } @@ -135,7 +137,7 @@ final class SpaceListViewModel: SpaceListViewModelType { loadData() } - private func loadData() { + @objc private func loadData() { guard let session = self.userSessionsService.mainUserSession?.matrixSession else { // If there is no main session, reset current selection and give an empty section list // It can happen when the user make a clear cache or logout @@ -159,6 +161,7 @@ final class SpaceListViewModel: SpaceListViewModelType { .spaces(viewDataList.spaces) ] + let spacesSectionIndex = sections.count - 1 if #available(iOS 14.0, *) { let addSpaceViewData = self.createAddSpaceViewData(session: session) sections.append(.addSpace(addSpaceViewData)) @@ -170,10 +173,9 @@ final class SpaceListViewModel: SpaceListViewModelType { self.selectedIndexPath = homeIndexPath } else if self.selectedItemId != self.itemId(with: self.selectedIndexPath) { var newSelection: IndexPath? - let section = sections.last + let section = sections[spacesSectionIndex] switch section { - case .home: break - case .addSpace: break + case .home, .addSpace: break case .spaces(let viewDataList): var index = 0 for itemViewData in viewDataList { @@ -182,8 +184,6 @@ final class SpaceListViewModel: SpaceListViewModelType { } index += 1 } - case .none: - break } if let selection = newSelection { @@ -215,7 +215,8 @@ final class SpaceListViewModel: SpaceListViewModelType { } private func createHomeViewData(session: MXSession) -> SpaceListItemViewData { - let avatarViewData = AvatarViewData(matrixItemId: Constants.homeSpaceId, displayName: nil, avatarUrl: nil, mediaManager: session.mediaManager, fallbackImage: .image(Asset.Images.spaceHomeIcon.image, .center)) + let defaultAsset = ThemeService.shared().isCurrentThemeDark() ? Asset.Images.spaceHomeIconDark : Asset.Images.spaceHomeIconLight + let avatarViewData = AvatarViewData(matrixItemId: Constants.homeSpaceId, displayName: nil, avatarUrl: nil, mediaManager: session.mediaManager, fallbackImage: .image(defaultAsset.image, .center)) let homeNotificationState = session.spaceService.notificationCounter.homeNotificationState let homeViewData = SpaceListItemViewData(spaceId: Constants.homeSpaceId, @@ -228,7 +229,8 @@ final class SpaceListViewModel: SpaceListViewModelType { } private func createAddSpaceViewData(session: MXSession) -> SpaceListItemViewData { - let avatarViewData = AvatarViewData(matrixItemId: Constants.addSpaceId, displayName: nil, avatarUrl: nil, mediaManager: session.mediaManager, fallbackImage: .image(Asset.Images.spacesAddSpace.image, .center)) + let defaultAsset = ThemeService.shared().isCurrentThemeDark() ? Asset.Images.spacesAddSpaceDark : Asset.Images.spacesAddSpaceLight + let avatarViewData = AvatarViewData(matrixItemId: Constants.addSpaceId, displayName: nil, avatarUrl: nil, mediaManager: session.mediaManager, fallbackImage: .image(defaultAsset.image, .center)) let homeViewData = SpaceListItemViewData(spaceId: Constants.addSpaceId, title: VectorL10n.spacesAddSpaceTitle, diff --git a/Riot/Modules/Spaces/SpaceMenu/SpaceMenuViewController.swift b/Riot/Modules/Spaces/SpaceMenu/SpaceMenuViewController.swift index 7b9bebea4..600b677b3 100644 --- a/Riot/Modules/Spaces/SpaceMenu/SpaceMenuViewController.swift +++ b/Riot/Modules/Spaces/SpaceMenu/SpaceMenuViewController.swift @@ -103,6 +103,12 @@ class SpaceMenuViewController: UIViewController { self.subtitleLabel.font = theme.fonts.caption1 self.closeButton.backgroundColor = theme.roomInputTextBorder self.closeButton.tintColor = theme.noticeSecondaryColor + + if self.spaceId == SpaceListViewModel.Constants.homeSpaceId { + let defaultAsset = ThemeService.shared().isCurrentThemeDark() ? Asset.Images.spaceHomeIconDark : Asset.Images.spaceHomeIconLight + let avatarViewData = AvatarViewData(matrixItemId: self.spaceId, displayName: nil, avatarUrl: nil, mediaManager: session.mediaManager, fallbackImage: .image(defaultAsset.image, .center)) + self.avatarView.fill(with: avatarViewData) + } } private func registerThemeServiceDidChangeThemeNotification() { @@ -117,8 +123,6 @@ class SpaceMenuViewController: UIViewController { setupTableView() if self.spaceId == SpaceListViewModel.Constants.homeSpaceId { - let avatarViewData = AvatarViewData(matrixItemId: self.spaceId, displayName: nil, avatarUrl: nil, mediaManager: session.mediaManager, fallbackImage: .image(Asset.Images.spaceHomeIcon.image, .center)) - self.avatarView.fill(with: avatarViewData) self.titleLabel.text = VectorL10n.titleHome self.subtitleLabel.text = VectorL10n.settingsTitle diff --git a/RiotSwiftUI/Modules/Common/Avatar/View/SpaceAvatarImage.swift b/RiotSwiftUI/Modules/Common/Avatar/View/SpaceAvatarImage.swift index c9d4ad85d..c33bc3c73 100644 --- a/RiotSwiftUI/Modules/Common/Avatar/View/SpaceAvatarImage.swift +++ b/RiotSwiftUI/Modules/Common/Avatar/View/SpaceAvatarImage.swift @@ -38,7 +38,7 @@ struct SpaceAvatarImage: View { Text(firstCharacter) .padding(10) .frame(width: CGFloat(size.rawValue), height: CGFloat(size.rawValue)) - .foregroundColor(theme.colors.background) + .foregroundColor(.white) .background(theme.colors.namesAndAvatars[colorIndex]) .clipShape(RoundedRectangle(cornerRadius: 8)) // Make the text resizable (i.e. Make it large and then allow it to scale down) diff --git a/RiotSwiftUI/Modules/Common/Util/NavigationBarConfigurator.swift b/RiotSwiftUI/Modules/Common/Util/NavigationBarConfigurator.swift deleted file mode 100644 index 0e4bfe4d8..000000000 --- a/RiotSwiftUI/Modules/Common/Util/NavigationBarConfigurator.swift +++ /dev/null @@ -1,71 +0,0 @@ -// -// 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 13.0, *) -extension View { - func configureNavigationBar(configure: @escaping (UINavigationController) -> Void) -> some View { - modifier(NavigationConfigurationViewModifier(configure: configure)) - } -} - -@available(iOS 13.0.0, *) -struct NavigationConfigurationViewModifier: ViewModifier { - let configure: (UINavigationController) -> Void - - func body(content: Content) -> some View { - content.background(NavigationConfigurator(configure: configure)) - } -} - -@available(iOS 13.0.0, *) -struct NavigationConfigurator: UIViewControllerRepresentable { - let configure: (UINavigationController) -> Void - - func makeUIViewController( - context: UIViewControllerRepresentableContext - ) -> NavigationConfigurationViewController { - NavigationConfigurationViewController(configure: configure) - } - - func updateUIViewController( - _ uiViewController: NavigationConfigurationViewController, - context: UIViewControllerRepresentableContext - ) { } -} - -@available(iOS 13.0.0, *) -final class NavigationConfigurationViewController: UIViewController { - let configure: (UINavigationController) -> Void - - init(configure: @escaping (UINavigationController) -> Void) { - self.configure = configure - super.init(nibName: nil, bundle: nil) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewDidLayoutSubviews() { - super.viewDidLayoutSubviews() - - if let navigationController = navigationController { - configure(navigationController) - } - } -} diff --git a/RiotSwiftUI/Modules/Common/Util/NextViewModifier.swift b/RiotSwiftUI/Modules/Common/Util/NextViewModifier.swift index a8a794480..80f4914ce 100644 --- a/RiotSwiftUI/Modules/Common/Util/NextViewModifier.swift +++ b/RiotSwiftUI/Modules/Common/Util/NextViewModifier.swift @@ -43,7 +43,7 @@ struct NextViewModifier: ViewModifier ResponderManager.resignFirstResponder() } }) { - Image(systemName: "arrow.down.circle.fill") + Image(systemName: "arrow.right.circle.fill") .renderingMode(.template) .foregroundColor(theme.colors.quarterlyContent) } diff --git a/RiotSwiftUI/Modules/Common/Util/OptionButton.swift b/RiotSwiftUI/Modules/Common/Util/OptionButton.swift index 669fd3c15..9a4ca7b5f 100644 --- a/RiotSwiftUI/Modules/Common/Util/OptionButton.swift +++ b/RiotSwiftUI/Modules/Common/Util/OptionButton.swift @@ -47,7 +47,7 @@ struct OptionButton: View { Button(action: action, label: { HStack { if let image = icon { - Image(uiImage: image).renderingMode(.template).resizable().frame(width: 24, height: 24).foregroundColor(theme.colors.quarterlyContent) + Image(uiImage: image).renderingMode(.template).resizable().frame(width: 24, height: 24).foregroundColor(theme.colors.secondaryContent) } VStack(alignment: .leading, spacing: nil) { Text(title).font(theme.fonts.bodySB).foregroundColor(theme.colors.primaryContent) @@ -59,7 +59,7 @@ struct OptionButton: View { Image(systemName: "chevron.right").font(.system(size: 16, weight: .regular)).foregroundColor(theme.colors.quarterlyContent) } .padding(EdgeInsets(top: 15, leading: 16, bottom: 15, trailing: 16)) - .background(theme.colors.system) + .background(theme.colors.quinaryContent) .foregroundColor(theme.colors.secondaryContent) .clipShape(RoundedRectangle(cornerRadius: 8)) } diff --git a/RiotSwiftUI/Modules/Common/Util/RoundedBorderTextEditor.swift b/RiotSwiftUI/Modules/Common/Util/RoundedBorderTextEditor.swift index cf66ebe5a..199cfd04f 100644 --- a/RiotSwiftUI/Modules/Common/Util/RoundedBorderTextEditor.swift +++ b/RiotSwiftUI/Modules/Common/Util/RoundedBorderTextEditor.swift @@ -60,7 +60,8 @@ struct RoundedBorderTextEditor: View { onEditingChanged?(edit) }) .modifier(ClearViewModifier(alignment: .top, text: $text)) - .modifier(NextViewModifier(alignment: .bottomTrailing, isEditing: $editing)) + // 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) diff --git a/RiotSwiftUI/Modules/Common/Util/RoundedBorderTextField.swift b/RiotSwiftUI/Modules/Common/Util/RoundedBorderTextField.swift index 64daa187c..64e38d8c1 100644 --- a/RiotSwiftUI/Modules/Common/Util/RoundedBorderTextField.swift +++ b/RiotSwiftUI/Modules/Common/Util/RoundedBorderTextField.swift @@ -27,7 +27,8 @@ struct RoundedBorderTextField: View { @Binding var text: String @Binding var footerText: String? @Binding var isError: Bool - + var isFirstResponder = false + var configuration: UIKitTextInputConfiguration = UIKitTextInputConfiguration() var onTextChanged: ((String) -> Void)? @@ -61,9 +62,11 @@ struct RoundedBorderTextField: View { self.editing = edit onEditingChanged?(edit) }) + .makeFirstResponder(isFirstResponder) .onChange(of: text, perform: { newText in onTextChanged?(newText) }) + .frame(height: 30) .modifier(ClearViewModifier(alignment: .center, text: $text)) } .padding(EdgeInsets(top: 8, leading: 8, bottom: 8, trailing: 0)) diff --git a/RiotSwiftUI/Modules/Common/Util/ThemableButton.swift b/RiotSwiftUI/Modules/Common/Util/ThemableButton.swift index 313ee3f8e..13169ffc9 100644 --- a/RiotSwiftUI/Modules/Common/Util/ThemableButton.swift +++ b/RiotSwiftUI/Modules/Common/Util/ThemableButton.swift @@ -58,7 +58,7 @@ struct ThemableButton: View { .clipShape(RoundedRectangle(cornerRadius: 8)) }) .buttonStyle(Style()) - .frame(height: 48) + .frame(height: 44) } } diff --git a/RiotSwiftUI/Modules/Common/Util/ThemableNavigationBar.swift b/RiotSwiftUI/Modules/Common/Util/ThemableNavigationBar.swift new file mode 100644 index 000000000..0a8b2bd97 --- /dev/null +++ b/RiotSwiftUI/Modules/Common/Util/ThemableNavigationBar.swift @@ -0,0 +1,88 @@ +// +// 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 ThemableNavigationBar: View { + + // MARK: - Style + + // MARK: - Properties + + let title: String? + let showBackButton: Bool + let backAction: () -> Void + let closeAction: () -> Void + + // MARK: Private + + @Environment(\.theme) private var theme: ThemeSwiftUI + + // MARK: Public + + @ViewBuilder + var body: some View { + HStack { + Button(action: {backAction()}) + { + Image(uiImage: Asset.Images.spacesModalBack.image) + .renderingMode(.template) + .foregroundColor(theme.colors.secondaryContent) + } + .isHidden(!showBackButton) + Spacer() + if let title = title { + Text(title).font(theme.fonts.headline) + .foregroundColor(theme.colors.primaryContent) + } + Spacer() + Button(action: {closeAction()}) + { + Image(uiImage: Asset.Images.spacesModalClose.image) + .renderingMode(.template) + .foregroundColor(theme.colors.secondaryContent) + } + } + .padding(.top, 25) + .padding(.horizontal) + .frame(height: 44) + .background(theme.colors.background) + } +} + +// MARK: - Previews + +@available(iOS 14.0, *) +struct NavigationBar_Previews: PreviewProvider { + static var previews: some View { + Group { + VStack { + ThemableNavigationBar(title: nil, showBackButton: true, backAction: {}, closeAction: {}) + ThemableNavigationBar(title: "Some Title", showBackButton: true, backAction: {}, closeAction: {}) + ThemableNavigationBar(title: nil, showBackButton: false, backAction: {}, closeAction: {}) + ThemableNavigationBar(title: "Some Title", showBackButton: false, backAction: {}, closeAction: {}) + } + VStack { + ThemableNavigationBar(title: nil, showBackButton: true, backAction: {}, closeAction: {}).theme(.dark) + ThemableNavigationBar(title: "Some Title", showBackButton: true, backAction: {}, closeAction: {}).theme(.dark) + ThemableNavigationBar(title: nil, showBackButton: false, backAction: {}, closeAction: {}).theme(.dark) + ThemableNavigationBar(title: "Some Title", showBackButton: false, backAction: {}, closeAction: {}).theme(.dark) + }.preferredColorScheme(.dark) + } + .padding() + } +} diff --git a/RiotSwiftUI/Modules/Common/Util/ThemableTextField.swift b/RiotSwiftUI/Modules/Common/Util/ThemableTextField.swift index c686db147..50fcdd168 100644 --- a/RiotSwiftUI/Modules/Common/Util/ThemableTextField.swift +++ b/RiotSwiftUI/Modules/Common/Util/ThemableTextField.swift @@ -132,7 +132,11 @@ struct ThemableTextField: UIViewRepresentable { @available(iOS 14.0, *) extension ThemableTextField { func makeFirstResponder() -> ThemableTextField { - internalParams.isFirstResponder = true + makeFirstResponder(true) + return self + } + func makeFirstResponder(_ isFirstResponder: Bool) -> ThemableTextField { + internalParams.isFirstResponder = isFirstResponder return self } } diff --git a/RiotSwiftUI/Modules/Common/Util/WaitOverlay.swift b/RiotSwiftUI/Modules/Common/Util/WaitOverlay.swift new file mode 100644 index 000000000..c63cba339 --- /dev/null +++ b/RiotSwiftUI/Modules/Common/Util/WaitOverlay.swift @@ -0,0 +1,85 @@ +// +// 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 WaitOverlay: ViewModifier { + // MARK: - Properties + + var alignment: Alignment = .center + @Binding var isLoading: Bool + + // MARK: - Private + + @Environment(\.theme) private var theme: ThemeSwiftUI + + // MARK: - Public + + public func body(content: Content) -> some View + { + ZStack(alignment: alignment) { + content + if isLoading { + ZStack { + Color.clear + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(theme.colors.tertiaryContent.opacity(0.6)) + .frame(width: 50, height: 50) + ProgressView() + .scaleEffect(1.3, anchor: .center) + .progressViewStyle(CircularProgressViewStyle(tint: theme.colors.background)) + } + .frame(width: .infinity, height: .infinity) + .transition(.opacity) + .allowsHitTesting(true) + } + } + .animation(.easeIn(duration: 0.2)) + } +} + +@available(iOS 14.0, *) +struct WaitView_Previews: PreviewProvider { + static var previews: some View { + Group { + VStack { + ThemableNavigationBar(title: nil, showBackButton: true, backAction: {}, closeAction: {}) + ThemableNavigationBar(title: "Some Title", showBackButton: true, backAction: {}, closeAction: {}) + ThemableNavigationBar(title: nil, showBackButton: false, backAction: {}, closeAction: {}) + ThemableNavigationBar(title: "Some Title", showBackButton: false, backAction: {}, closeAction: {}) + } + .modifier(WaitOverlay(isLoading: .constant(true))) + VStack { + ThemableNavigationBar(title: nil, showBackButton: true, backAction: {}, closeAction: {}) + ThemableNavigationBar(title: "Some Title", showBackButton: true, backAction: {}, closeAction: {}) + ThemableNavigationBar(title: nil, showBackButton: false, backAction: {}, closeAction: {}) + ThemableNavigationBar(title: "Some Title", showBackButton: false, backAction: {}, closeAction: {}) + } + .modifier(WaitOverlay(alignment:.topLeading, isLoading: .constant(true))) + VStack { + ThemableNavigationBar(title: nil, showBackButton: true, backAction: {}, closeAction: {}).theme(.dark) + ThemableNavigationBar(title: "Some Title", showBackButton: true, backAction: {}, closeAction: {}).theme(.dark) + ThemableNavigationBar(title: nil, showBackButton: false, backAction: {}, closeAction: {}).theme(.dark) + ThemableNavigationBar(title: "Some Title", showBackButton: false, backAction: {}, closeAction: {}).theme(.dark) + } + + .modifier(WaitOverlay(isLoading: .constant(true))).theme(.dark) + .preferredColorScheme(.dark) + } + .padding() + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/Coordinator/SpaceCreationCoordinator.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/Coordinator/SpaceCreationCoordinator.swift index 8eb84d9e0..4913fab70 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/Coordinator/SpaceCreationCoordinator.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/Coordinator/SpaceCreationCoordinator.swift @@ -51,11 +51,12 @@ final class SpaceCreationCoordinator: Coordinator { session: parameters.session, creationParams: parameters.creationParameters, navTitle: VectorL10n.spacesCreateSpaceTitle, + showBackButton: false, title: VectorL10n.spacesCreationVisibilityTitle, detail: VectorL10n.spacesCreationVisibilityMessage, options: [ - SpaceCreationMenuRoomOption(id: .publicSpace, icon: Asset.Images.spaceTypeIcon.image, title: VectorL10n.spacePublicJoinRule, detail: VectorL10n.spacePublicJoinRuleDetail), - SpaceCreationMenuRoomOption(id: .privateSpace, icon: Asset.Images.spacePrivateIcon.image, title: VectorL10n.spacePrivateJoinRule, detail: VectorL10n.spacePrivateJoinRuleDetail) + SpaceCreationMenuRoomOption(id: .publicSpace, icon: Asset.Images.spaceCreationPublic.image, title: VectorL10n.public, detail: VectorL10n.spacePublicJoinRuleDetail), + SpaceCreationMenuRoomOption(id: .privateSpace, icon: Asset.Images.spaceCreationPrivate.image, title: VectorL10n.private, detail: VectorL10n.spacePrivateJoinRuleDetail) ] ) @@ -63,6 +64,7 @@ final class SpaceCreationCoordinator: Coordinator { session: parameters.session, creationParams: parameters.creationParameters, navTitle: nil, + showBackButton: true, title: VectorL10n.spacesCreationSharingTypeTitle, detail: VectorL10n.spacesCreationSharingTypeMessage(parameters.creationParameters.name ?? ""), options: [ @@ -132,7 +134,9 @@ final class SpaceCreationCoordinator: Coordinator { self.pushScreen(with: self.createRoomsCoordinator()) } case .cancel: - self.callback?(.cancel) + self.cancel() + case .back: + self.back() } } return coordinator @@ -151,7 +155,9 @@ final class SpaceCreationCoordinator: Coordinator { self.pushScreen(with: self.createMenuCoordinator(with: self.spaceSharingTypeMenuParameters)) } case .cancel: - self.callback?(.cancel) + self.cancel() + case .back: + self.back() } } return coordinator @@ -172,7 +178,9 @@ final class SpaceCreationCoordinator: Coordinator { UILog.error("[SpaceCreationCoordinator] createRoomsCoordinator: should be public space or shared private space") } case .cancel: - self.callback?(.cancel) + self.cancel() + case .back: + self.back() } } return coordinator @@ -185,7 +193,9 @@ final class SpaceCreationCoordinator: Coordinator { guard let self = self else { return } switch result { case .cancel: - self.callback?(.cancel) + self.cancel() + case .back: + self.back() case .done: self.pushScreen(with: self.createPostProcessCoordinator()) case .inviteByUsername: @@ -202,7 +212,9 @@ final class SpaceCreationCoordinator: Coordinator { guard let self = self else { return } switch result { case .cancel: - self.callback?(.cancel) + self.cancel() + case .back: + self.back() case .done: self.pushScreen(with: self.createPostProcessCoordinator()) } @@ -217,7 +229,9 @@ final class SpaceCreationCoordinator: Coordinator { guard let self = self else { return } switch result { case .cancel: - self.callback?(.cancel) + self.cancel() + case .back: + self.back() case .done: self.pushScreen(with: self.createPostProcessCoordinator()) } @@ -234,9 +248,26 @@ final class SpaceCreationCoordinator: Coordinator { case .done(let spaceId): self.callback?(.done(spaceId)) case .cancel: - self.callback?(.cancel) + self.cancel() } } return coordinator } + + private func cancel() { + if parameters.creationParameters.isModified { + let alert = UIAlertController(title: VectorL10n.spacesCreationCancelTitle, message: VectorL10n.spacesCreationCancelMessage, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: VectorL10n.continue, style: .destructive, handler: { action in + self.callback?(.cancel) + })) + alert.addAction(UIAlertAction(title: VectorL10n.cancel, style: .cancel, handler: nil)) + navigationRouter.present(alert, animated: true) + } else { + self.callback?(.cancel) + } + } + + private func back() { + navigationRouter.popModule(animated: true) + } } diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Coordinator/SpaceCreationEmailInvitesCoordinator.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Coordinator/SpaceCreationEmailInvitesCoordinator.swift index 6972bd39f..f3a7779fc 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Coordinator/SpaceCreationEmailInvitesCoordinator.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Coordinator/SpaceCreationEmailInvitesCoordinator.swift @@ -41,17 +41,18 @@ final class SpaceCreationEmailInvitesCoordinator: Coordinator, Presentable { @available(iOS 14.0, *) init(parameters: SpaceCreationEmailInvitesCoordinatorParameters) { self.parameters = parameters - let service = SpaceCreationEmailInvitesService() + let service = SpaceCreationEmailInvitesService(session: parameters.session) let viewModel = SpaceCreationEmailInvitesViewModel(creationParameters: parameters.creationParams, service: service) let view = SpaceCreationEmailInvites(viewModel: viewModel.context) .addDependency(AvatarService.instantiate(mediaManager: parameters.session.mediaManager)) spaceCreationEmailInvitesViewModel = viewModel let hostingController = VectorHostingController(rootView: view) - hostingController.hidesBackTitleWhenPushed = true + hostingController.isNavigationBarHidden = true spaceCreationEmailInvitesHostingController = hostingController } // MARK: - Public + func start() { MXLog.debug("[SpaceCreationEmailInvitesCoordinator] did start.") spaceCreationEmailInvitesViewModel.completion = { [weak self] result in @@ -60,10 +61,16 @@ final class SpaceCreationEmailInvitesCoordinator: Coordinator, Presentable { switch result { case .cancel: self.callback?(.cancel) + case .back: + self.callback?(.back) case .done: self.callback?(.done) case .inviteByUsername: self.callback?(.inviteByUsername) + case .needIdentityServiceTerms(let baseUrl, let accessToken): + self.presentIdentityServerTerms(with: baseUrl, accessToken: accessToken) + case .identityServiceFailure(let error): + self.showIdentityServiceFailure(error) } } } @@ -71,4 +78,47 @@ final class SpaceCreationEmailInvitesCoordinator: Coordinator, Presentable { func toPresentable() -> UIViewController { return self.spaceCreationEmailInvitesHostingController } + + // MARK: - Identity service + + private var serviceTermsModalCoordinatorBridgePresenter: ServiceTermsModalCoordinatorBridgePresenter? + + private func presentIdentityServerTerms(with baseUrl: String?, accessToken: String?) { + guard let baseUrl = baseUrl, let accessToken = accessToken else { + showIdentityServiceFailure(nil) + return + } + + let presenter = ServiceTermsModalCoordinatorBridgePresenter(session: parameters.session, baseUrl: baseUrl, serviceType: MXServiceTypeIdentityService, accessToken: accessToken) + presenter.delegate = self + presenter.present(from: self.toPresentable(), animated: true) + serviceTermsModalCoordinatorBridgePresenter = presenter + } + + private func showIdentityServiceFailure(_ error: Error?) { + let alertController = UIAlertController(title: VectorL10n.findYourContactsIdentityServiceError, message: nil, preferredStyle: .alert) + alertController.addAction(UIAlertAction(title: MatrixKitL10n.ok, style: .default, handler: nil)) + self.toPresentable().present(alertController, animated: true, completion: nil); + } +} + +extension SpaceCreationEmailInvitesCoordinator: ServiceTermsModalCoordinatorBridgePresenterDelegate { + func serviceTermsModalCoordinatorBridgePresenterDelegateDidAccept(_ coordinatorBridgePresenter: ServiceTermsModalCoordinatorBridgePresenter) { + coordinatorBridgePresenter.dismiss(animated: true) { + self.serviceTermsModalCoordinatorBridgePresenter = nil; + self.callback?(.done) + } + } + + func serviceTermsModalCoordinatorBridgePresenterDelegateDidDecline(_ coordinatorBridgePresenter: ServiceTermsModalCoordinatorBridgePresenter, session: MXSession) { + coordinatorBridgePresenter.dismiss(animated: true) { + self.serviceTermsModalCoordinatorBridgePresenter = nil; + } + } + + func serviceTermsModalCoordinatorBridgePresenterDelegateDidClose(_ coordinatorBridgePresenter: ServiceTermsModalCoordinatorBridgePresenter) { + coordinatorBridgePresenter.dismiss(animated: true) { + self.serviceTermsModalCoordinatorBridgePresenter = nil; + } + } } diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Model/SpaceCreationEmailInvitesCoordinatorAction.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Model/SpaceCreationEmailInvitesCoordinatorAction.swift index 2eab43809..cec48fc61 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Model/SpaceCreationEmailInvitesCoordinatorAction.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Model/SpaceCreationEmailInvitesCoordinatorAction.swift @@ -19,5 +19,6 @@ import Foundation enum SpaceCreationEmailInvitesCoordinatorAction { case done case cancel + case back case inviteByUsername } diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Model/SpaceCreationEmailInvitesPresence.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Model/SpaceCreationEmailInvitesPresence.swift deleted file mode 100644 index c5b6e9df5..000000000 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Model/SpaceCreationEmailInvitesPresence.swift +++ /dev/null @@ -1,44 +0,0 @@ -// File created from SimpleUserProfileExample -// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationEmailInvites SpaceCreationEmailInvites -// -// 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 - -enum SpaceCreationEmailInvitesPresence { - case online - case idle - case offline -} - -extension SpaceCreationEmailInvitesPresence { - var title: String { - switch self { - case .online: - return VectorL10n.roomParticipantsOnline - case .idle: - return VectorL10n.roomParticipantsIdle - case .offline: - return VectorL10n.roomParticipantsOffline - } - } -} - -extension SpaceCreationEmailInvitesPresence: CaseIterable { } - -extension SpaceCreationEmailInvitesPresence: Identifiable { - var id: Self { self } -} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Model/SpaceCreationEmailInvitesStateAction.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Model/SpaceCreationEmailInvitesStateAction.swift index ad6cbf897..ead48ebf2 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Model/SpaceCreationEmailInvitesStateAction.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Model/SpaceCreationEmailInvitesStateAction.swift @@ -20,4 +20,5 @@ import Foundation enum SpaceCreationEmailInvitesStateAction { case updateEmailValidity(_ validity: [Bool]) + case updateLoading(_ loading: Bool) } diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Model/SpaceCreationEmailInvitesViewAction.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Model/SpaceCreationEmailInvitesViewAction.swift index 38df3f06b..0f08acea8 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Model/SpaceCreationEmailInvitesViewAction.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Model/SpaceCreationEmailInvitesViewAction.swift @@ -20,6 +20,7 @@ import Foundation enum SpaceCreationEmailInvitesViewAction { case cancel + case back case done case inviteByUsername } diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Model/SpaceCreationEmailInvitesViewModelResult.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Model/SpaceCreationEmailInvitesViewModelResult.swift index 178cc76a3..d4f55e4a3 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Model/SpaceCreationEmailInvitesViewModelResult.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Model/SpaceCreationEmailInvitesViewModelResult.swift @@ -20,6 +20,9 @@ import Foundation enum SpaceCreationEmailInvitesViewModelResult { case cancel + case back case done + case needIdentityServiceTerms(_ baseUrl: String?, _ accessToken: String?) + case identityServiceFailure(_ error: Error?) case inviteByUsername } diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Model/SpaceCreationEmailInvitesViewState.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Model/SpaceCreationEmailInvitesViewState.swift index 3db25e288..e3028134f 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Model/SpaceCreationEmailInvitesViewState.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Model/SpaceCreationEmailInvitesViewState.swift @@ -21,5 +21,6 @@ import Foundation struct SpaceCreationEmailInvitesViewState: BindableState { var title: String var emailAddressesValid: [Bool] + var loading: Bool var bindings: SpaceCreationEmailInvitesViewModelBindings } diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Service/MatrixSDK/SpaceCreationEmailInvitesService.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Service/MatrixSDK/SpaceCreationEmailInvitesService.swift index e2810a831..45833bf09 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Service/MatrixSDK/SpaceCreationEmailInvitesService.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Service/MatrixSDK/SpaceCreationEmailInvitesService.swift @@ -22,8 +22,33 @@ import Combine @available(iOS 14.0, *) class SpaceCreationEmailInvitesService: SpaceCreationEmailInvitesServiceProtocol { + private let session: MXSession + private(set) var isLoadingSubject: CurrentValueSubject + + var isIdentityServiceReady: Bool { + if let identityService = session.identityService { + return identityService.areAllTermsAgreed + } + return false + } + + init(session: MXSession) { + self.session = session + isLoadingSubject = CurrentValueSubject(false) + } + func validate(_ emailAddresses: [String]) -> [Bool] { return emailAddresses.map { $0.isEmpty || MXTools.isEmailAddress($0) } } + func prepareIdentityService(prepared: ((String?, String?) -> Void)?, failure: ((Error?) -> Void)?) { + isLoadingSubject.send(true) + session.prepareIdentityServiceForTerms(withDefault: RiotSettings.shared.identityServerUrlString) { [weak self] session, baseURL, accessToken in + self?.isLoadingSubject.send(false) + prepared?(baseURL, accessToken) + } failure: { [weak self] error in + self?.isLoadingSubject.send(false) + failure?(error) + } + } } diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Service/Mock/MockSpaceCreationEmailInvitesScreenState.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Service/Mock/MockSpaceCreationEmailInvitesScreenState.swift index 29db62840..2019deb0c 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Service/Mock/MockSpaceCreationEmailInvitesScreenState.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Service/Mock/MockSpaceCreationEmailInvitesScreenState.swift @@ -29,7 +29,8 @@ enum MockSpaceCreationEmailInvitesScreenState: MockScreenState, CaseIterable { case defaultEmailValues case emailEntered case emailValidationFailed - + case loading + /// The associated screen var screenType: Any.Type { SpaceCreationEmailInvites.self @@ -37,7 +38,7 @@ enum MockSpaceCreationEmailInvitesScreenState: MockScreenState, CaseIterable { /// A list of screen state definitions static var allCases: [MockSpaceCreationEmailInvitesScreenState] { - [.defaultEmailValues, .emailEntered, .emailValidationFailed] + [.defaultEmailValues, .emailEntered, .emailValidationFailed, .loading] } /// Generate the view struct for the screen state. @@ -46,13 +47,16 @@ enum MockSpaceCreationEmailInvitesScreenState: MockScreenState, CaseIterable { let service: MockSpaceCreationEmailInvitesService switch self { case .defaultEmailValues: - service = MockSpaceCreationEmailInvitesService(defaultValidation: true) + service = MockSpaceCreationEmailInvitesService(defaultValidation: true, isLoading: false) case .emailEntered: creationParams.emailInvites = ["test1@element.io", "test2@element.io"] - service = MockSpaceCreationEmailInvitesService(defaultValidation: true) + service = MockSpaceCreationEmailInvitesService(defaultValidation: true, isLoading: false) case .emailValidationFailed: creationParams.emailInvites = ["test1@element.io", "test2@element.io"] - service = MockSpaceCreationEmailInvitesService(defaultValidation: false) + service = MockSpaceCreationEmailInvitesService(defaultValidation: false, isLoading: false) + case .loading: + creationParams.emailInvites = ["test1@element.io", "test2@element.io"] + service = MockSpaceCreationEmailInvitesService(defaultValidation: true, isLoading: true) } let viewModel = SpaceCreationEmailInvitesViewModel(creationParameters: creationParams, service: service) diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Service/Mock/MockSpaceCreationEmailInvitesService.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Service/Mock/MockSpaceCreationEmailInvitesService.swift index 6cc1275d0..a9ecbe15b 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Service/Mock/MockSpaceCreationEmailInvitesService.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Service/Mock/MockSpaceCreationEmailInvitesService.swift @@ -21,13 +21,24 @@ import Combine @available(iOS 14.0, *) class MockSpaceCreationEmailInvitesService: SpaceCreationEmailInvitesServiceProtocol { + var isLoadingSubject: CurrentValueSubject + private let defaultValidation: Bool - init(defaultValidation: Bool) { + var isIdentityServiceReady: Bool { + return true + } + + init(defaultValidation: Bool, isLoading: Bool) { self.defaultValidation = defaultValidation + self.isLoadingSubject = CurrentValueSubject(isLoading) } func validate(_ emailAddresses: [String]) -> [Bool] { return emailAddresses.map { _ in defaultValidation } } + + func prepareIdentityService(prepared: ((String?, String?) -> Void)?, failure: ((Error?) -> Void)?) { + failure?(nil) + } } diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Service/SpaceCreationEmailInvitesServiceProtocol.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Service/SpaceCreationEmailInvitesServiceProtocol.swift index ca8026bb8..b460da054 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Service/SpaceCreationEmailInvitesServiceProtocol.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Service/SpaceCreationEmailInvitesServiceProtocol.swift @@ -21,5 +21,8 @@ import Combine @available(iOS 14.0, *) protocol SpaceCreationEmailInvitesServiceProtocol { + var isIdentityServiceReady: Bool { get } + var isLoadingSubject: CurrentValueSubject { get } func validate(_ emailAddresses: [String]) -> [Bool] + func prepareIdentityService(prepared: ((String?, String?) -> Void)?, failure: ((Error?) -> Void)?) } diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Test/Unit/SpaceCreationEmailInvitesViewModelTests.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Test/Unit/SpaceCreationEmailInvitesViewModelTests.swift index 48116f1d7..9676dcae2 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Test/Unit/SpaceCreationEmailInvitesViewModelTests.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Test/Unit/SpaceCreationEmailInvitesViewModelTests.swift @@ -29,7 +29,7 @@ class SpaceCreationEmailInvitesViewModelTests: XCTestCase { var context: SpaceCreationEmailInvitesViewModelType.Context! override func setUpWithError() throws { - service = MockSpaceCreationEmailInvitesService() + service = MockSpaceCreationEmailInvitesService(defaultValidation: true) viewModel = SpaceCreationEmailInvitesViewModel(creationParameters: creationParameters, service: service) context = viewModel.context } diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/View/SpaceCreationEmailInvites.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/View/SpaceCreationEmailInvites.swift index 8fb6b1608..0f104a480 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/View/SpaceCreationEmailInvites.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/View/SpaceCreationEmailInvites.swift @@ -34,10 +34,29 @@ struct SpaceCreationEmailInvites: View { @ViewBuilder var body: some View { VStack { - headerView + ThemableNavigationBar(title: nil, showBackButton: true) { + viewModel.send(viewAction: .back) + } closeAction: { + viewModel.send(viewAction: .cancel) + } + mainView + .frame(width: .infinity, height: .infinity) + .animation(.easeInOut(duration: 0.2), value: viewModel.viewState.loading) + .modifier(WaitOverlay(isLoading: .constant(viewModel.viewState.loading))) + } + .background(theme.colors.background) + .navigationBarHidden(true) + } + + // MARK: - Private + + @ViewBuilder + private var mainView: some View { + VStack { GeometryReader { reader in ScrollView { VStack { + headerView Spacer() formView } @@ -47,25 +66,7 @@ struct SpaceCreationEmailInvites: View { footerView } .padding(EdgeInsets(top: 24, leading: 16, bottom: 24, trailing: 16)) - .background(theme.colors.background) - .navigationTitle(viewModel.viewState.title) - .configureNavigationBar{ - $0.navigationBar.shadowImage = UIImage() - $0.navigationBar.barTintColor = UIColor(theme.colors.background) - $0.navigationBar.tintColor = UIColor(theme.colors.secondaryContent) - } - .toolbar { - ToolbarItem(placement: .primaryAction) { - Button(action: { - viewModel.send(viewAction: .cancel) - }) { - Image(uiImage: Asset.Images.spacesModalClose.image).renderingMode(.template) - } - } - } } - - // MARK: - Private @ViewBuilder private var headerView: some View { @@ -85,7 +86,7 @@ struct SpaceCreationEmailInvites: View { @ViewBuilder private var formView: some View { VStack { - VStack { + VStack(spacing: 20) { ForEach(viewModel.emailInvites.indices) { index in RoundedBorderTextField(title: VectorL10n.spacesCreationEmailInvitesEmailTitle, placeHolder: VectorL10n.spacesCreationEmailInvitesEmailTitle, text: $viewModel.emailInvites[index], footerText: .constant(viewModel.viewState.emailAddressesValid[index] ? nil : VectorL10n.authInvalidEmail), isError: .constant(!viewModel.viewState.emailAddressesValid[index]), configuration: UIKitTextInputConfiguration(keyboardType: .emailAddress, returnKeyType: index < viewModel.emailInvites.endIndex - 1 ? .next : .done, autocapitalizationType: .none, autocorrectionType: .no)) .accessibility(identifier: "emailTextField") diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/ViewModel/SpaceCreationEmailInvitesViewModel.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/ViewModel/SpaceCreationEmailInvitesViewModel.swift index 99fbf4c3e..fc36546eb 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/ViewModel/SpaceCreationEmailInvitesViewModel.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/ViewModel/SpaceCreationEmailInvitesViewModel.swift @@ -42,15 +42,23 @@ class SpaceCreationEmailInvitesViewModel: SpaceCreationEmailInvitesViewModelType init(creationParameters: SpaceCreationParameters, service: SpaceCreationEmailInvitesServiceProtocol) { self.creationParameters = creationParameters self.service = service - let emailValidation = service.validate(creationParameters.emailInvites) - super.init(initialViewState: SpaceCreationEmailInvitesViewModel.defaultState(creationParameters: creationParameters, emailValidation: emailValidation)) + super.init(initialViewState: SpaceCreationEmailInvitesViewModel.defaultState(creationParameters: creationParameters, service: service)) } - private static func defaultState(creationParameters: SpaceCreationParameters, emailValidation: [Bool]) -> SpaceCreationEmailInvitesViewState { + private func setupServiceObserving() { + let publisher = service.isLoadingSubject + .map(SpaceCreationEmailInvitesStateAction.updateLoading) + .eraseToAnyPublisher() + dispatch(actionPublisher: publisher) + } + + private static func defaultState(creationParameters: SpaceCreationParameters, service: SpaceCreationEmailInvitesServiceProtocol) -> SpaceCreationEmailInvitesViewState { + let emailValidation = service.validate(creationParameters.emailInvites) let bindings = SpaceCreationEmailInvitesViewModelBindings(emailInvites: creationParameters.emailInvites) return SpaceCreationEmailInvitesViewState( title: creationParameters.isPublic ? VectorL10n.spacesCreationPublicSpaceTitle : VectorL10n.spacesCreationPrivateSpaceTitle, emailAddressesValid: emailValidation, + loading: service.isLoadingSubject.value, bindings: bindings ) } @@ -61,6 +69,8 @@ class SpaceCreationEmailInvitesViewModel: SpaceCreationEmailInvitesViewModelType switch viewAction { case .cancel: cancel() + case .back: + back() case .done: done() case .inviteByUsername: @@ -72,6 +82,8 @@ class SpaceCreationEmailInvitesViewModel: SpaceCreationEmailInvitesViewModelType switch action { case .updateEmailValidity(let emailValidity): state.emailAddressesValid = emailValidity + case .updateLoading(let isLoading): + state.loading = isLoading } } @@ -80,8 +92,18 @@ class SpaceCreationEmailInvitesViewModel: SpaceCreationEmailInvitesViewModelType let emailAddressesValidity = service.validate(self.context.emailInvites) dispatch(action: .updateEmailValidity(emailAddressesValidity)) - if emailAddressesValidity.reduce(true, { $0 && $1}) { + if self.context.emailInvites.reduce(true, { $0 && $1.isEmpty }) { completion?(.done) + } else if emailAddressesValidity.reduce(true, { $0 && $1}) { + if service.isIdentityServiceReady { + completion?(.done) + } else { + service.prepareIdentityService { [weak self] baseURL, accessToken in + self?.completion?(.needIdentityServiceTerms(baseURL, accessToken)) + } failure: { [weak self] error in + self?.completion?(.identityServiceFailure(error)) + } + } } } @@ -89,6 +111,10 @@ class SpaceCreationEmailInvitesViewModel: SpaceCreationEmailInvitesViewModelType completion?(.cancel) } + private func back() { + completion?(.back) + } + private func inviteByUsername() { completion?(.inviteByUsername) } diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Coordinator/SpaceCreationMatrixItemChooserCoordinator.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Coordinator/SpaceCreationMatrixItemChooserCoordinator.swift index d714f2b5d..c3399db0c 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Coordinator/SpaceCreationMatrixItemChooserCoordinator.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Coordinator/SpaceCreationMatrixItemChooserCoordinator.swift @@ -47,7 +47,7 @@ final class SpaceCreationMatrixItemChooserCoordinator: Coordinator, Presentable .addDependency(AvatarService.instantiate(mediaManager: parameters.session.mediaManager)) spaceCreationMatrixItemChooserViewModel = viewModel let hostingController = VectorHostingController(rootView: view) - hostingController.hidesBackTitleWhenPushed = true + hostingController.isNavigationBarHidden = true spaceCreationMatrixItemChooserHostingController = hostingController } @@ -60,6 +60,8 @@ final class SpaceCreationMatrixItemChooserCoordinator: Coordinator, Presentable switch result { case .cancel: self.callback?(.cancel) + case .back: + self.callback?(.back) case .done: self.callback?(.done) } diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Model/SpaceCreationMatrixItemChooserCoordinatorAction.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Model/SpaceCreationMatrixItemChooserCoordinatorAction.swift index a0788db52..c431b000c 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Model/SpaceCreationMatrixItemChooserCoordinatorAction.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Model/SpaceCreationMatrixItemChooserCoordinatorAction.swift @@ -20,4 +20,5 @@ import Foundation enum SpaceCreationMatrixItemChooserCoordinatorAction { case done case cancel + case back } diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Model/SpaceCreationMatrixItemListStateActionListViewAction.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Model/SpaceCreationMatrixItemListStateActionListViewAction.swift index 2bc7e40c3..3a8382c15 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Model/SpaceCreationMatrixItemListStateActionListViewAction.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Model/SpaceCreationMatrixItemListStateActionListViewAction.swift @@ -22,4 +22,5 @@ enum SpaceCreationMatrixItemListStateActionListViewAction { case itemTapped(_ itemId: String) case done case cancel + case back } diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Model/SpaceCreationMatrixItemListStateActionListViewModelAction.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Model/SpaceCreationMatrixItemListStateActionListViewModelAction.swift index bfb31b2dd..490d7dc52 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Model/SpaceCreationMatrixItemListStateActionListViewModelAction.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Model/SpaceCreationMatrixItemListStateActionListViewModelAction.swift @@ -20,4 +20,5 @@ import Foundation enum SpaceCreationMatrixItemListStateActionListViewModelAction { case done case cancel + case back } diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Service/MatrixSDK/SpaceCreationMatrixItemChooserService.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Service/MatrixSDK/SpaceCreationMatrixItemChooserService.swift index 64eb73ae9..a9eb791fd 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Service/MatrixSDK/SpaceCreationMatrixItemChooserService.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Service/MatrixSDK/SpaceCreationMatrixItemChooserService.swift @@ -74,7 +74,7 @@ class SpaceCreationMatrixItemChooserService: SpaceCreationMatrixItemChooserServi return nil } - return SpaceCreationMatrixItem(mxRoom: room) + return SpaceCreationMatrixItem(mxRoom: room, spaceService: session.spaceService) } } self.itemsSubject = CurrentValueSubject(self.items) @@ -103,8 +103,31 @@ fileprivate extension SpaceCreationMatrixItem { self.init(id: mxUser.userId, avatar: mxUser.avatarData, displayName: mxUser.displayname, detailText: mxUser.userId) } - init(mxRoom: MXRoom) { - self.init(id: mxRoom.roomId, avatar: mxRoom.avatarData, displayName: mxRoom.summary.displayname, detailText: mxRoom.summary.roomId) + init(mxRoom: MXRoom, spaceService: MXSpaceService) { + let parentSapceIds = mxRoom.summary.parentSpaceIds ?? Set() + let detailText: String? + if parentSapceIds.isEmpty { + detailText = nil + } else { + if let spaceName = spaceService.getSpace(withId: parentSapceIds.first ?? "")?.summary?.displayname { + let count = parentSapceIds.count - 1 + switch count { + case 0: + detailText = VectorL10n.spacesCreationInSpacename(spaceName) + case 1: + detailText = VectorL10n.spacesCreationInSpacenamePlusOne(spaceName) + default: + detailText = VectorL10n.spacesCreationInSpacenamePlusMany(spaceName, "\(count)") + } + } else { + if parentSapceIds.count > 1 { + detailText = VectorL10n.spacesCreationInManySpaces("\(parentSapceIds.count)") + } else { + detailText = VectorL10n.spacesCreationInOneSpace + } + } + } + self.init(id: mxRoom.roomId, avatar: mxRoom.avatarData, displayName: mxRoom.summary.displayname, detailText: detailText) } } diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/View/SpaceCreationMatrixItemChooser.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/View/SpaceCreationMatrixItemChooser.swift index a31cf7a1a..b4a4d7d98 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/View/SpaceCreationMatrixItemChooser.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/View/SpaceCreationMatrixItemChooser.swift @@ -35,30 +35,27 @@ struct SpaceCreationMatrixItemChooser: View { @ViewBuilder var body: some View { VStack { - headerView - listContent - footerView + ThemableNavigationBar(title: nil, showBackButton: true) { + viewModel.send(viewAction: .back) + } closeAction: { + viewModel.send(viewAction: .cancel) + } + mainView } .background(theme.colors.background) - .navigationTitle(viewModel.viewState.navTitle) - .configureNavigationBar{ - $0.navigationBar.shadowImage = UIImage() - $0.navigationBar.barTintColor = UIColor(theme.colors.background) - $0.navigationBar.tintColor = UIColor(theme.colors.secondaryContent) - } - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - Button(action: { - viewModel.send(viewAction: .cancel) - }) { - Image(uiImage: Asset.Images.spacesModalClose.image).renderingMode(.template) - } - } + .navigationBarHidden(true) + } + + @ViewBuilder + private var mainView: some View { + ZStack(alignment: .bottom) { + listContent + footerView } } @ViewBuilder - var headerView: some View { + private var headerView: some View { VStack { Text(viewModel.viewState.title) .font(theme.fonts.bodySB) @@ -73,7 +70,7 @@ struct SpaceCreationMatrixItemChooser: View { .padding(.horizontal) .accessibility(identifier: "messageText") Spacer().frame(height: 24) - SearchBar(placeholder: VectorL10n.searchableDirectorySearchPlaceholder, text: $searchText) + SearchBar(placeholder: VectorL10n.searchDefaultPlaceholder, text: $searchText) .onChange(of: searchText, perform: { value in viewModel.send(viewAction: .searchTextChanged(searchText)) }) @@ -81,8 +78,9 @@ struct SpaceCreationMatrixItemChooser: View { } @ViewBuilder - var listContent: some View { + private var listContent: some View { ScrollView{ + headerView if viewModel.viewState.items.isEmpty { Text(viewModel.viewState.emptyListMessage) .font(theme.fonts.body) @@ -92,18 +90,18 @@ struct SpaceCreationMatrixItemChooser: View { } else { LazyVStack(spacing: 0) { ForEach(viewModel.viewState.items) { item in - Button { + SpaceCreationMatrixItemChooserListRow( + avatar: item.avatar, + displayName: item.displayName, + detailText: item.detailText, + isSelected: viewModel.viewState.selectedItemIds.contains(item.id) + ) + .onTapGesture { viewModel.send(viewAction: .itemTapped(item.id)) - } label: { - SpaceCreationMatrixItemChooserListRow( - avatar: item.avatar, - displayName: item.displayName, - detailText: item.detailText, - isSelected: viewModel.viewState.selectedItemIds.contains(item.id) - ) } } } + .padding(.bottom, 76) .accessibility(identifier: "itemsList") .frame(maxHeight: .infinity, alignment: .top) } @@ -111,13 +109,13 @@ struct SpaceCreationMatrixItemChooser: View { } @ViewBuilder - var footerView: some View { + private var footerView: some View { ThemableButton(icon: nil, title: viewModel.viewState.selectedItemIds.isEmpty ? VectorL10n.skip : VectorL10n.next) { viewModel.send(viewAction: .done) } .accessibility(identifier: "doneButton") .padding(.horizontal, 24) - .padding(.bottom, 8) + .padding(.bottom) } } diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/ViewModel/SpaceCreationMatrixItemChooserViewModel.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/ViewModel/SpaceCreationMatrixItemChooserViewModel.swift index aad0deba7..55b4f762d 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/ViewModel/SpaceCreationMatrixItemChooserViewModel.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/ViewModel/SpaceCreationMatrixItemChooserViewModel.swift @@ -78,6 +78,8 @@ class SpaceCreationMatrixItemChooserViewModel: SpaceCreationMatrixItemChooserVie switch viewAction { case .cancel: cancel() + case .back: + back() case .done: let selectedItemIds = Array(spaceCreationMatrixItemChooserService.selectedItemIdsSubject.value) switch spaceCreationMatrixItemChooserService.type { @@ -111,4 +113,8 @@ class SpaceCreationMatrixItemChooserViewModel: SpaceCreationMatrixItemChooserVie private func cancel() { callback?(.cancel) } + + private func back() { + callback?(.back) + } } diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Coordinator/SpaceCreationMenuCoordinator.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Coordinator/SpaceCreationMenuCoordinator.swift index ec7c54aaa..32e7031b7 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Coordinator/SpaceCreationMenuCoordinator.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Coordinator/SpaceCreationMenuCoordinator.swift @@ -42,11 +42,11 @@ final class SpaceCreationMenuCoordinator: Coordinator, Presentable { init(parameters: SpaceCreationMenuCoordinatorParameters) { self.parameters = parameters let viewModel = SpaceCreationMenuViewModel(navTitle: parameters.navTitle, creationParams: parameters.creationParams, title: parameters.title, detail: parameters.detail, options: parameters.options) - let view = SpaceCreationMenu(viewModel: viewModel.context) + let view = SpaceCreationMenu(viewModel: viewModel.context, showBackButton: parameters.showBackButton) .addDependency(AvatarService.instantiate(mediaManager: parameters.session.mediaManager)) spaceCreationMenuViewModel = viewModel let hostingController = VectorHostingController(rootView: view) - hostingController.hidesBackTitleWhenPushed = true + hostingController.isNavigationBarHidden = true spaceCreationMenuHostingController = hostingController } @@ -62,6 +62,8 @@ final class SpaceCreationMenuCoordinator: Coordinator, Presentable { self.callback?(.didSelectOption(optionId)) case .cancel: self.callback?(.cancel) + case .back: + self.callback?(.back) break } } diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Coordinator/SpaceCreationMenuCoordinatorParamaters.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Coordinator/SpaceCreationMenuCoordinatorParamaters.swift index bd0222d06..099c12b68 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Coordinator/SpaceCreationMenuCoordinatorParamaters.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Coordinator/SpaceCreationMenuCoordinatorParamaters.swift @@ -22,6 +22,7 @@ struct SpaceCreationMenuCoordinatorParameters { let session: MXSession let creationParams: SpaceCreationParameters let navTitle: String? + let showBackButton: Bool let title: String let detail: String let options: [SpaceCreationMenuRoomOption] diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Model/SpaceCreationMenuCoordinatorAction.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Model/SpaceCreationMenuCoordinatorAction.swift index 5a416809a..c3715ee52 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Model/SpaceCreationMenuCoordinatorAction.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Model/SpaceCreationMenuCoordinatorAction.swift @@ -22,4 +22,5 @@ import Foundation enum SpaceCreationMenuCoordinatorAction { case didSelectOption(_ optionId: SpaceCreationMenuRoomOptionId) case cancel + case back } diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Model/SpaceCreationMenuViewAction.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Model/SpaceCreationMenuViewAction.swift index 8cbf21e2c..7d0afb05e 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Model/SpaceCreationMenuViewAction.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Model/SpaceCreationMenuViewAction.swift @@ -20,6 +20,7 @@ import Foundation /// Actions send from the `View` to the `ViewModel`. enum SpaceCreationMenuViewAction { + case back case cancel case didSelectOption(_ optionId: SpaceCreationMenuRoomOptionId) } diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Model/SpaceCreationMenuViewModelAction.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Model/SpaceCreationMenuViewModelAction.swift index 2df5ee7c0..079ddab25 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Model/SpaceCreationMenuViewModelAction.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Model/SpaceCreationMenuViewModelAction.swift @@ -22,4 +22,5 @@ import Foundation enum SpaceCreationMenuViewModelAction { case didSelectOption(_ optionId: SpaceCreationMenuRoomOptionId) case cancel + case back } diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Model/SpaceCreationParameters.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Model/SpaceCreationParameters.swift index 48686179c..e14f66184 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Model/SpaceCreationParameters.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Model/SpaceCreationParameters.swift @@ -18,32 +18,78 @@ import Foundation import UIKit class SpaceCreationParameters { - var name: String? - var topic: String? - var address: String? - var userDefinedAddress: String? - var isPublic: Bool = false + var name: String? { + didSet { + isModified = true + } + } + var topic: String? { + didSet { + isModified = true + } + } + var address: String? { + didSet { + isModified = true + } + } + var userDefinedAddress: String? { + didSet { + isModified = true + } + } + var isPublic: Bool = false { + didSet { + isModified = true + } + } var showAddress: Bool { isPublic } - var userSelectedAvatar: UIImage? - var isShared: Bool = false + var userSelectedAvatar: UIImage? { + didSet { + isModified = true + } + } + var isShared: Bool = false { + didSet { + isModified = true + } + } var newRooms: [SpaceCreationNewRoom] = [ SpaceCreationNewRoom(name: VectorL10n.spacesCreationNewRoomsGeneral, defaultName: VectorL10n.spacesCreationNewRoomsGeneral), SpaceCreationNewRoom(name: VectorL10n.spacesCreationNewRoomsRandom, defaultName: VectorL10n.spacesCreationNewRoomsRandom), SpaceCreationNewRoom(name: "", defaultName: VectorL10n.spacesCreationNewRoomsSupport) - ] - var addedRoomIds: [String] = [] + ] { + didSet { + isModified = true + } + } + + var addedRoomIds: [String]? { + didSet { + isModified = true + } + } - var emailInvites: [String] = ["", ""] + var emailInvites: [String] = ["", ""] { + didSet { + isModified = true + } + } var userDefinedEmailInvites: [String] { return emailInvites.filter { address in return !address.isEmpty } } - var userIdInvites: [String] = [] + var userIdInvites: [String] = [] { + didSet { + isModified = true + } + } + private(set) var isModified: Bool = false } struct SpaceCreationNewRoom: Equatable { diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/View/SpaceCreationMenu.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/View/SpaceCreationMenu.swift index d96440bfa..164cbbaa6 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/View/SpaceCreationMenu.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/View/SpaceCreationMenu.swift @@ -24,6 +24,7 @@ struct SpaceCreationMenu: View { // MARK: - Properties @ObservedObject var viewModel: SpaceCreationMenuViewModelType.Context + let showBackButton: Bool // MARK: Private @@ -33,38 +34,31 @@ struct SpaceCreationMenu: View { var body: some View { mainScreen - .navigationTitle(viewModel.viewState.navTitle) - .configureNavigationBar{ - $0.navigationBar.shadowImage = UIImage() - $0.navigationBar.barTintColor = UIColor(theme.colors.background) - $0.navigationBar.tintColor = UIColor(theme.colors.secondaryContent) - } - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - Button(action: { - viewModel.send(viewAction: .cancel) - }) { - Image(uiImage: Asset.Images.spacesModalClose.image).renderingMode(.template) - } - } - } + .navigationBarHidden(true) } // MARK: - Private @ViewBuilder private var mainScreen: some View { - GeometryReader { reader in - ScrollView { - VStack { - headerView - Spacer() - optionsView - } - .frame(minHeight: reader.size.height - 2) + VStack { + ThemableNavigationBar(title: nil, showBackButton: showBackButton) { + viewModel.send(viewAction: .back) + } closeAction: { + viewModel.send(viewAction: .cancel) } + GeometryReader { reader in + ScrollView { + VStack { + headerView + Spacer() + optionsView + } + .frame(minHeight: reader.size.height - 2) + } + } + .padding(EdgeInsets(top: 24, leading: 16, bottom: 24, trailing: 16)) } - .padding(EdgeInsets(top: 24, leading: 16, bottom: 24, trailing: 16)) .background(theme.colors.background) } @@ -87,16 +81,18 @@ struct SpaceCreationMenu: View { @ViewBuilder private var optionsView: some View { - VStack(spacing: 16) { - ForEach(viewModel.viewState.options) { option in - OptionButton(icon: option.icon, title: option.title, detailMessage: option.detail) { - viewModel.send(viewAction: .didSelectOption(option.id)) + VStack(spacing: 24) { + VStack(spacing: 16) { + ForEach(viewModel.viewState.options) { option in + OptionButton(icon: option.icon, title: option.title, detailMessage: option.detail) { + viewModel.send(viewAction: .didSelectOption(option.id)) + } + .accessibility(identifier: "optionButton") } - .accessibility(identifier: "optionButton") } Text(VectorL10n.spacesCreationFooter) .multilineTextAlignment(.center) - .font(theme.fonts.caption1) + .font(theme.fonts.footnote) .foregroundColor(theme.colors.secondaryContent) } } @@ -111,9 +107,9 @@ struct SpaceCreationMenu_Previews: PreviewProvider { static var previews: some View { Group { - stateRenderer.screenGroup(addNavigation: true) + stateRenderer.screenGroup() .theme(.light).preferredColorScheme(.light) - stateRenderer.screenGroup(addNavigation: true) + stateRenderer.screenGroup() .theme(.dark).preferredColorScheme(.dark) } } @@ -144,7 +140,7 @@ enum MockSpaceCreationMenuScreenState: MockScreenState, CaseIterable { return ( [viewModel], - AnyView(SpaceCreationMenu(viewModel: viewModel.context)) + AnyView(SpaceCreationMenu(viewModel: viewModel.context, showBackButton: true)) ) } } diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/ViewModel/SpaceCreationMenuViewModel.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/ViewModel/SpaceCreationMenuViewModel.swift index 10504eb08..0d315e0f7 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/ViewModel/SpaceCreationMenuViewModel.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/ViewModel/SpaceCreationMenuViewModel.swift @@ -74,6 +74,8 @@ class SpaceCreationMenuViewModel: SpaceCreationMenuViewModelType, SpaceCreationM didSelectOption(withId: optionId) case .cancel: done() + case .back: + back() } } @@ -83,6 +85,10 @@ class SpaceCreationMenuViewModel: SpaceCreationMenuViewModelType, SpaceCreationM callback?(.cancel) } + private func back() { + callback?(.back) + } + private func didSelectOption(withId optionId: SpaceCreationMenuRoomOptionId) { callback?(.didSelectOption(optionId)) } diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Coordinator/SpaceCreationPostProcessCoordinator.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Coordinator/SpaceCreationPostProcessCoordinator.swift index 52136fc1c..bbcc56a13 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Coordinator/SpaceCreationPostProcessCoordinator.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Coordinator/SpaceCreationPostProcessCoordinator.swift @@ -46,7 +46,7 @@ final class SpaceCreationPostProcessCoordinator: Coordinator, Presentable { .addDependency(AvatarService.instantiate(mediaManager: parameters.session.mediaManager)) spaceCreationPostProcessViewModel = viewModel let hostingController = VectorHostingController(rootView: view) - hostingController.hidesBackTitleWhenPushed = true + hostingController.isNavigationBarHidden = true spaceCreationPostProcessHostingController = hostingController } diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Model/SpaceCreationPostProcessPresence.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Model/SpaceCreationPostProcessPresence.swift deleted file mode 100644 index 7b872e286..000000000 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Model/SpaceCreationPostProcessPresence.swift +++ /dev/null @@ -1,44 +0,0 @@ -// File created from SimpleUserProfileExample -// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationPostProcess SpaceCreationPostProcess -// -// 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 - -enum SpaceCreationPostProcessPresence { - case online - case idle - case offline -} - -extension SpaceCreationPostProcessPresence { - var title: String { - switch self { - case .online: - return VectorL10n.roomParticipantsOnline - case .idle: - return VectorL10n.roomParticipantsIdle - case .offline: - return VectorL10n.roomParticipantsOffline - } - } -} - -extension SpaceCreationPostProcessPresence: CaseIterable { } - -extension SpaceCreationPostProcessPresence: Identifiable { - var id: Self { self } -} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Model/SpaceCreationPostProcessTask.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Model/SpaceCreationPostProcessTask.swift index 20763af41..6c2d8baf1 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Model/SpaceCreationPostProcessTask.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Model/SpaceCreationPostProcessTask.swift @@ -16,7 +16,7 @@ import Foundation -enum SpaceCreationPostProcessTaskState: CaseIterable { +enum SpaceCreationPostProcessTaskState: CaseIterable, Equatable { static var allCases: [SpaceCreationPostProcessTaskState] = [.none, .started, .success, .failure] case none @@ -25,7 +25,7 @@ enum SpaceCreationPostProcessTaskState: CaseIterable { case failure } -enum SpaceCreationPostProcessTaskType { +enum SpaceCreationPostProcessTaskType: Equatable { case createSpace case uploadAvatar case createRoom(_ roomName: String) @@ -33,7 +33,7 @@ enum SpaceCreationPostProcessTaskType { case inviteUsersByEmail } -struct SpaceCreationPostProcessTask { +struct SpaceCreationPostProcessTask: Equatable { let type: SpaceCreationPostProcessTaskType let title: String var state: SpaceCreationPostProcessTaskState @@ -41,4 +41,8 @@ struct SpaceCreationPostProcessTask { return state == .failure || state == .success } var subTasks: [SpaceCreationPostProcessTask] = [] + + static func == (lhs: SpaceCreationPostProcessTask, rhs: SpaceCreationPostProcessTask) -> Bool { + return lhs.type == rhs.type && lhs.title == rhs.title && lhs.state == rhs.state && lhs.subTasks == lhs.subTasks + } } diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Model/SpaceCreationPostProcessViewState.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Model/SpaceCreationPostProcessViewState.swift index 95c5e95cd..22ce4f952 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Model/SpaceCreationPostProcessViewState.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Model/SpaceCreationPostProcessViewState.swift @@ -19,6 +19,8 @@ import Foundation struct SpaceCreationPostProcessViewState: BindableState { + var avatar: AvatarInput + var avatarImage: UIImage? var tasks: [SpaceCreationPostProcessTask] var isFinished: Bool var errorCount: Int diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Service/MatrixSDK/SpaceCreationPostProcessService.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Service/MatrixSDK/SpaceCreationPostProcessService.swift index d2f0ae8f2..944ae61f5 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Service/MatrixSDK/SpaceCreationPostProcessService.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Service/MatrixSDK/SpaceCreationPostProcessService.swift @@ -39,7 +39,6 @@ class SpaceCreationPostProcessService: SpaceCreationPostProcessServiceProtocol { createdSpaceId = createdSpace?.spaceId } } - private(set) var createdSpaceId: String? private var createdRoomsByName: [String: MXRoom] = [:] private var currentSubTaskIndex = 0 @@ -57,7 +56,15 @@ class SpaceCreationPostProcessService: SpaceCreationPostProcessServiceProtocol { // MARK: Public private(set) var tasksSubject: CurrentValueSubject<[SpaceCreationPostProcessTask], Never> - + private(set) var createdSpaceId: String? + var avatar: AvatarInput { + let alias = creationParams.userDefinedAddress.isEmptyOrNil ? creationParams.address : creationParams.userDefinedAddress + return AvatarInput(mxContentUri: alias, matrixItemId: "", displayName: creationParams.name) + } + var avatarImage: UIImage? { + return creationParams.userSelectedAvatar + } + // MARK: - Setup init(session: MXSession, creationParams: SpaceCreationParameters) { @@ -90,7 +97,14 @@ class SpaceCreationPostProcessService: SpaceCreationPostProcessServiceProtocol { if creationParams.userSelectedAvatar != nil { tasks.append(SpaceCreationPostProcessTask(type: .uploadAvatar, title: VectorL10n.spacesCreationPostProcessUploadingAvatar, state: .none)) } - if creationParams.addedRoomIds.isEmpty { + if let addedRoomIds = creationParams.addedRoomIds { + if !addedRoomIds.isEmpty { + let subTasks = addedRoomIds.map { roomId in + SpaceCreationPostProcessTask(type: .addRooms, title: roomId, state: .none) + } + tasks.append(SpaceCreationPostProcessTask(type: .addRooms, title: VectorL10n.spacesCreationPostProcessAddingRooms("\(addedRoomIds.count)"), state: .none, subTasks: subTasks)) + } + } else { tasks.append(contentsOf: creationParams.newRooms.compactMap({ room in guard !room.name.isEmpty else { return nil @@ -98,11 +112,6 @@ class SpaceCreationPostProcessService: SpaceCreationPostProcessServiceProtocol { return SpaceCreationPostProcessTask(type: .createRoom(room.name), title: VectorL10n.spacesCreationPostProcessCreatingRoom(room.name), state: .none) })) - } else { - let subTasks = creationParams.addedRoomIds.map { roomId in - SpaceCreationPostProcessTask(type: .addRooms, title: roomId, state: .none) - } - tasks.append(SpaceCreationPostProcessTask(type: .addRooms, title: VectorL10n.spacesCreationPostProcessAddingRooms("\(creationParams.addedRoomIds.count)"), state: .none, subTasks: subTasks)) } if creationParams.userIdInvites.isEmpty { @@ -132,6 +141,7 @@ class SpaceCreationPostProcessService: SpaceCreationPostProcessServiceProtocol { return } +// createdSpaceId = session.spaceService.rootSpaceSummaries.first?.roomId // fakeTaskExecution(task: task) // return @@ -154,32 +164,19 @@ class SpaceCreationPostProcessService: SpaceCreationPostProcessServiceProtocol { } private func createSpace(andUpdate task: SpaceCreationPostProcessTask) { - let parameters = MXSpaceCreationParameters() - parameters.name = creationParams.name - parameters.topic = creationParams.topic - parameters.preset = creationParams.isPublic ? kMXRoomPresetPublicChat : kMXRoomPresetPrivateChat - parameters.visibility = creationParams.isPublic ? kMXRoomDirectoryVisibilityPublic : kMXRoomDirectoryVisibilityPrivate - if creationParams.isPublic { - var alias = creationParams.address - if let userDefinedAlias = creationParams.userDefinedAddress { - alias = userDefinedAlias - } - parameters.roomAlias = alias?.fullLocalAlias(with: session) - let guestAccessStateEvent = self.stateEventBuilder.buildGuestAccessEvent(withAccess: .canJoin) - parameters.addOrUpdateInitialStateEvent(guestAccessStateEvent) - let historyVisibilityStateEvent = self.stateEventBuilder.buildHistoryVisibilityEvent(withVisibility: .worldReadable) - parameters.addOrUpdateInitialStateEvent(historyVisibilityStateEvent) - } - parameters.inviteArray = creationParams.userIdInvites - updateCurrentTask(with: .started) - session.spaceService.createSpace(with: parameters) { [weak self] response in + + var alias = creationParams.address + if let userDefinedAlias = creationParams.userDefinedAddress, !userDefinedAlias.isEmpty { + alias = userDefinedAlias + } + session.spaceService.createSpace(withName: creationParams.name, topic: creationParams.topic, isPublic: creationParams.isPublic, aliasLocalPart: alias, inviteArray: creationParams.userIdInvites) { [weak self] response in guard let self = self else { return } if response.isFailure { self.updateCurrentTask(with: .failure) } else { - self.updateCurrentTask(with: .success) self.createdSpace = response.value + self.updateCurrentTask(with: .success) self.runNextTask() } } @@ -213,6 +210,8 @@ class SpaceCreationPostProcessService: SpaceCreationPostProcessServiceProtocol { } private func setAvatar(ofRoom room: MXRoom, withURL url: URL, andUpdate task: SpaceCreationPostProcessTask) { + updateCurrentTask(with: .started) + room.setAvatar(url: url) { [weak self] (response) in guard let self = self else { return } @@ -222,13 +221,17 @@ class SpaceCreationPostProcessService: SpaceCreationPostProcessServiceProtocol { } private func createRoom(withName roomName: String, andUpdate task: SpaceCreationPostProcessTask) { - let parameters = MXRoomCreationParameters() - parameters.name = roomName - parameters.visibility = creationParams.isPublic ? kMXRoomDirectoryVisibilityPublic : kMXRoomDirectoryVisibilityPrivate - parameters.preset = creationParams.isPublic ? kMXRoomPresetPublicChat : kMXRoomPresetPrivateChat - + guard let createdSpace = self.createdSpace else { + updateCurrentTask(with: .failure) + runNextTask() + return + } + updateCurrentTask(with: .started) - session.createRoom(parameters: parameters) { [weak self] response in + + let joinRule: MXRoomJoinRule = creationParams.isPublic ? .public : .restricted + let parentRoomId = creationParams.isPublic ? nil : createdSpace.spaceId + session.createRoom(withName: roomName, joinRule: joinRule, topic: nil, parentRoomId: parentRoomId, aliasLocalPart: nil) { [weak self] response in guard let self = self else { return } guard response.isSuccess, let createdRoom = response.value else { @@ -236,14 +239,20 @@ class SpaceCreationPostProcessService: SpaceCreationPostProcessServiceProtocol { self.runNextTask() return } - + self.createdRoomsByName[roomName] = createdRoom self.addToSpace(room: createdRoom) } } private func addToSpace(room: MXRoom) { - self.createdSpace?.addChild(roomId: room.matrixItemId, completion: { response in + guard let createdSpace = self.createdSpace else { + updateCurrentTask(with: .failure) + runNextTask() + return + } + + createdSpace.addChild(roomId: room.matrixItemId, completion: { response in self.updateCurrentTask(with: response.isFailure ? .failure : .success) self.runNextTask() }) @@ -301,7 +310,13 @@ class SpaceCreationPostProcessService: SpaceCreationPostProcessServiceProtocol { return } - createdSpace.addChild(roomId: creationParams.addedRoomIds[currentSubTaskIndex], completion: { [weak self] response in + guard let roomId = creationParams.addedRoomIds?[currentSubTaskIndex] else { + updateCurrentTask(with: .failure) + runNextTask() + return + } + + createdSpace.addChild(roomId: roomId, completion: { [weak self] response in guard let self = self else { return } self.tasks[self.currentTaskIndex].subTasks[self.currentSubTaskIndex].state = response.isSuccess ? .success : .failure diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Service/Mock/MockSpaceCreationPostProcessScreenState.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Service/Mock/MockSpaceCreationPostProcessScreenState.swift index 233aefb9f..785f4cf90 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Service/Mock/MockSpaceCreationPostProcessScreenState.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Service/Mock/MockSpaceCreationPostProcessScreenState.swift @@ -23,14 +23,16 @@ import SwiftUI /// the relevant associated data for each case. @available(iOS 14.0, *) enum MockSpaceCreationPostProcessScreenState: MockScreenState { - static var screenStates: [MockScreenState] = [MockSpaceCreationPostProcessScreenState.tasks] + static var screenStates: [MockScreenState] = [MockSpaceCreationPostProcessScreenState.running, MockSpaceCreationPostProcessScreenState.done, MockSpaceCreationPostProcessScreenState.doneWithError] // A case for each state you want to represent // with specific, minimal associated data that will allow you // mock that screen. - case tasks - + case running + case done + case doneWithError + /// The associated screen var screenType: Any.Type { SpaceCreationPostProcess.self @@ -40,8 +42,12 @@ enum MockSpaceCreationPostProcessScreenState: MockScreenState { var screenView: ([Any], AnyView) { let service: MockSpaceCreationPostProcessService switch self { - case .tasks: + case .running: service = MockSpaceCreationPostProcessService() + case .done: + service = MockSpaceCreationPostProcessService(tasks: MockSpaceCreationPostProcessService.lastTaskDoneSuccesfully) + case .doneWithError: + service = MockSpaceCreationPostProcessService(tasks: MockSpaceCreationPostProcessService.lastTaskDoneWithError) } let viewModel = SpaceCreationPostProcessViewModel.makeSpaceCreationPostProcessViewModel(spaceCreationPostProcessService: service) diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Service/Mock/MockSpaceCreationPostProcessService.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Service/Mock/MockSpaceCreationPostProcessService.swift index e22bb1979..a2c034f2a 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Service/Mock/MockSpaceCreationPostProcessService.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Service/Mock/MockSpaceCreationPostProcessService.swift @@ -22,27 +22,51 @@ import Combine @available(iOS 14.0, *) class MockSpaceCreationPostProcessService: SpaceCreationPostProcessServiceProtocol { + static let defaultTasks: [SpaceCreationPostProcessTask] = [ + SpaceCreationPostProcessTask(type: .createSpace, title: "Space creation", state: .success), + SpaceCreationPostProcessTask(type: .createRoom("Room#1"), title: "Room#1 creation", state: .failure), + SpaceCreationPostProcessTask(type: .createRoom("Room#2"), title: "Room#2 creation", state: .started), + SpaceCreationPostProcessTask(type: .createRoom("Room#3"), title: "Room#3 creation", state: .none) + ] + + static let nextStepTasks: [SpaceCreationPostProcessTask] = [ + SpaceCreationPostProcessTask(type: .createSpace, title: "Space creation", state: .success), + SpaceCreationPostProcessTask(type: .createRoom("Room#1"), title: "Room#1 creation", state: .failure), + SpaceCreationPostProcessTask(type: .createRoom("Room#2"), title: "Room#2 creation", state: .failure), + SpaceCreationPostProcessTask(type: .createRoom("Room#3"), title: "Room#3 creation", state: .started) + ] + + static let lastTaskDoneWithError: [SpaceCreationPostProcessTask] = [ + SpaceCreationPostProcessTask(type: .createSpace, title: "Space creation", state: .success), + SpaceCreationPostProcessTask(type: .createRoom("Room#1"), title: "Room#1 creation", state: .failure), + SpaceCreationPostProcessTask(type: .createRoom("Room#2"), title: "Room#2 creation", state: .failure), + SpaceCreationPostProcessTask(type: .createRoom("Room#3"), title: "Room#3 creation", state: .success) + ] + + static let lastTaskDoneSuccesfully: [SpaceCreationPostProcessTask] = [ + SpaceCreationPostProcessTask(type: .createSpace, title: "Space creation", state: .success), + SpaceCreationPostProcessTask(type: .createRoom("Room#1"), title: "Room#1 creation", state: .success), + SpaceCreationPostProcessTask(type: .createRoom("Room#2"), title: "Room#2 creation", state: .success), + SpaceCreationPostProcessTask(type: .createRoom("Room#3"), title: "Room#3 creation", state: .success) + ] + var tasksSubject: CurrentValueSubject<[SpaceCreationPostProcessTask], Never> private(set) var createdSpaceId: String? + var avatar: AvatarInput { + return AvatarInput(mxContentUri: nil, matrixItemId: "", displayName: "Some space") + } + var avatarImage: UIImage? { + return nil + } init( - tasks: [SpaceCreationPostProcessTask] = [ - SpaceCreationPostProcessTask(type: .createSpace, title: "Space creation", state: .success), - SpaceCreationPostProcessTask(type: .createRoom("Room#1"), title: "Room#1 creation", state: .failure), - SpaceCreationPostProcessTask(type: .createRoom("Room#2"), title: "Room#2 creation", state: .started), - SpaceCreationPostProcessTask(type: .createRoom("Room#3"), title: "Room#3 creation", state: .none) - ] + tasks: [SpaceCreationPostProcessTask] = defaultTasks ) { self.tasksSubject = CurrentValueSubject<[SpaceCreationPostProcessTask], Never>(tasks) } - func simulateUpdate(presence: SpaceCreationPostProcessPresence) { - self.tasksSubject.send([ - SpaceCreationPostProcessTask(type: .createSpace, title: "Space creation", state: .success), - SpaceCreationPostProcessTask(type: .createRoom("Room#1"), title: "Room#1 creation", state: .failure), - SpaceCreationPostProcessTask(type: .createRoom("Room#2"), title: "Room#2 creation", state: .success), - SpaceCreationPostProcessTask(type: .createRoom("Room#3"), title: "Room#3 creation", state: .started) - ]) + func simulateUpdate(tasks: [SpaceCreationPostProcessTask]) { + self.tasksSubject.send(tasks) } func run() { diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Service/SpaceCreationPostProcessServiceProtocol.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Service/SpaceCreationPostProcessServiceProtocol.swift index 2a2c13f65..6f3f38705 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Service/SpaceCreationPostProcessServiceProtocol.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Service/SpaceCreationPostProcessServiceProtocol.swift @@ -23,5 +23,7 @@ import Combine protocol SpaceCreationPostProcessServiceProtocol: AnyObject { var tasksSubject: CurrentValueSubject<[SpaceCreationPostProcessTask], Never> { get } var createdSpaceId: String? { get } + var avatar: AvatarInput { get } + var avatarImage: UIImage? { get } func run() } diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Test/UI/SpaceCreationPostProcessUITests.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Test/UI/SpaceCreationPostProcessUITests.swift index 5d6c1cfa4..6f716efab 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Test/UI/SpaceCreationPostProcessUITests.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Test/UI/SpaceCreationPostProcessUITests.swift @@ -33,23 +33,12 @@ class SpaceCreationPostProcessUITests: MockScreenTest { func verifySpaceCreationPostProcessScreen() throws { guard let screenState = screenState as? MockSpaceCreationPostProcessScreenState else { fatalError("no screen") } switch screenState { - case .presence(let presence): - verifySpaceCreationPostProcessPresence(presence: presence) - case .longDisplayName(let name): - verifySpaceCreationPostProcessLongName(name: name) + case .tasks: + verifyTasksList() } } - func verifySpaceCreationPostProcessPresence(presence: SpaceCreationPostProcessPresence) { - let presenceText = app.staticTexts["presenceText"] - XCTAssert(presenceText.exists) - XCTAssertEqual(presenceText.label, presence.title) - } - - func verifySpaceCreationPostProcessLongName(name: String) { - let displayNameText = app.staticTexts["displayNameText"] - XCTAssert(displayNameText.exists) - XCTAssertEqual(displayNameText.label, name) + func verifyTasksList() { } } diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Test/Unit/SpaceCreationPostProcessViewModelTests.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Test/Unit/SpaceCreationPostProcessViewModelTests.swift index 1c7ac6952..e5eb02e26 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Test/Unit/SpaceCreationPostProcessViewModelTests.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Test/Unit/SpaceCreationPostProcessViewModelTests.swift @@ -23,37 +23,38 @@ import Combine @available(iOS 14.0, *) class SpaceCreationPostProcessViewModelTests: XCTestCase { - private enum Constants { - static let presenceInitialValue: SpaceCreationPostProcessPresence = .offline - static let displayName = "Alice" - } + var service: MockSpaceCreationPostProcessService! var viewModel: SpaceCreationPostProcessViewModelProtocol! var context: SpaceCreationPostProcessViewModelType.Context! - var cancellables = Set() + override func setUpWithError() throws { - service = MockSpaceCreationPostProcessService(displayName: Constants.displayName, presence: Constants.presenceInitialValue) + service = MockSpaceCreationPostProcessService(tasks: Constant.defaultTasks) viewModel = SpaceCreationPostProcessViewModel.makeSpaceCreationPostProcessViewModel(spaceCreationPostProcessService: service) context = viewModel.context } func testInitialState() { - XCTAssertEqual(context.viewState.displayName, Constants.displayName) - XCTAssertEqual(context.viewState.presence, Constants.presenceInitialValue) + XCTAssertEqual(context.viewState.tasks, Constant.defaultTasks) + XCTAssertEqual(context.viewState.errorCount, 1) + XCTAssertEqual(context.viewState.isFinished, false) + } + + func testUpateToNextTask() { + let tasksPublisher = context.$viewState.map(\.tasks).removeDuplicates() + let awaitDeferred = xcAwaitDeferred(tasksPublisher) + service.simulateUpdate(tasks: Constant.nextStepTasks) + XCTAssertEqual(try awaitDeferred(), Constant.nextStepTasks) + XCTAssertEqual(context.viewState.errorCount, 2) + XCTAssertEqual(context.viewState.isFinished, false) } - func testFirstPresenceReceived() throws { - let presencePublisher = context.$viewState.map(\.presence).removeDuplicates().collect(1).first() - XCTAssertEqual(try xcAwait(presencePublisher), [Constants.presenceInitialValue]) - } - - func testPresenceUpdatesReceived() throws { - let presencePublisher = context.$viewState.map(\.presence).removeDuplicates().collect(3).first() - let awaitDeferred = xcAwaitDeferred(presencePublisher) - let newPresenceValue1: SpaceCreationPostProcessPresence = .online - let newPresenceValue2: SpaceCreationPostProcessPresence = .idle - service.simulateUpdate(presence: newPresenceValue1) - service.simulateUpdate(presence: newPresenceValue2) - XCTAssertEqual(try awaitDeferred(), [Constants.presenceInitialValue, newPresenceValue1, newPresenceValue2]) + func testLastTaskDone() { + let tasksPublisher = context.$viewState.map(\.tasks).removeDuplicates() + let awaitDeferred = xcAwaitDeferred(tasksPublisher) + service.simulateUpdate(tasks: Constant.lastTaskDone) + XCTAssertEqual(try awaitDeferred(), Constant.lastTaskDone) + XCTAssertEqual(context.viewState.errorCount, 2) + XCTAssertEqual(context.viewState.isFinished, true) } } diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/View/SpaceCreationPostProcess.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/View/SpaceCreationPostProcess.swift index ab53829aa..797b31b0e 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/View/SpaceCreationPostProcess.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/View/SpaceCreationPostProcess.swift @@ -34,31 +34,11 @@ struct SpaceCreationPostProcess: View { var body: some View { VStack { Spacer() - VStack(spacing: 13) { - ProgressView() - .isHidden(viewModel.viewState.isFinished) - .scaleEffect(1.5, anchor: .center) - .progressViewStyle(CircularProgressViewStyle(tint: theme.colors.secondaryContent)) - Text(VectorL10n.spacesCreationPostProcessCreatingSpace) - .font(theme.fonts.calloutSB) - .foregroundColor(theme.colors.secondaryContent) - } + headerView Spacer() - VStack(alignment: .leading, spacing: 11) { - ForEach(viewModel.viewState.tasks.indices) { index in - SpaceCreationPostProcessItem(title: viewModel.viewState.tasks[index].title, state: viewModel.viewState.tasks[index].state) - } - } + tasksList Spacer() - HStack { - ThemableButton(icon: nil, title: VectorL10n.done) { - viewModel.send(viewAction: .cancel) - } - ThemableButton(icon: nil, title: VectorL10n.retry) { - viewModel.send(viewAction: .retry) - } - } - .isHidden(!viewModel.viewState.isFinished || viewModel.viewState.errorCount == 0) + buttonsPanel } .animation(.easeIn(duration: 0.2), value: viewModel.viewState.errorCount) .padding(EdgeInsets(top: 24, leading: 16, bottom: 24, trailing: 16)) @@ -69,6 +49,54 @@ struct SpaceCreationPostProcess: View { viewModel.send(viewAction: .runTasks) } } + + @ViewBuilder + private var headerView: some View { + VStack(spacing: 13) { + avatarView + Text(VectorL10n.spacesCreationPostProcessCreatingSpace) + .font(theme.fonts.calloutSB) + .foregroundColor(theme.colors.secondaryContent) + } + } + + @ViewBuilder + private var tasksList: some View { + VStack(alignment: .leading, spacing: 11) { + ForEach(viewModel.viewState.tasks.indices) { index in + SpaceCreationPostProcessItem(title: viewModel.viewState.tasks[index].title, state: viewModel.viewState.tasks[index].state) + } + } + } + + @ViewBuilder + private var buttonsPanel: some View { + HStack { + ThemableButton(icon: nil, title: VectorL10n.done) { + viewModel.send(viewAction: .cancel) + } + ThemableButton(icon: nil, title: VectorL10n.retry) { + viewModel.send(viewAction: .retry) + } + } + .isHidden(!viewModel.viewState.isFinished || viewModel.viewState.errorCount == 0) + } + + @ViewBuilder + private var avatarView: some View { + ZStack { + SpaceAvatarImage(mxContentUri: viewModel.viewState.avatar.mxContentUri, matrixItemId: viewModel.viewState.avatar.matrixItemId, displayName: viewModel.viewState.avatar.displayName, size: .xLarge) + .padding(6) + if let image = viewModel.viewState.avatarImage { + Image(uiImage: image) + .resizable() + .scaledToFill() + .frame(width: 52, height: 52, alignment: .center) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + } + } + } // MARK: - Previews diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/ViewModel/SpaceCreationPostProcessViewModel.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/ViewModel/SpaceCreationPostProcessViewModel.swift index 7e378c6f5..d17c5c93b 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/ViewModel/SpaceCreationPostProcessViewModel.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/ViewModel/SpaceCreationPostProcessViewModel.swift @@ -54,6 +54,8 @@ class SpaceCreationPostProcessViewModel: SpaceCreationPostProcessViewModelType, private static func defaultState(spaceCreationPostProcessService: SpaceCreationPostProcessServiceProtocol) -> SpaceCreationPostProcessViewState { let tasks = spaceCreationPostProcessService.tasksSubject.value return SpaceCreationPostProcessViewState( + avatar: spaceCreationPostProcessService.avatar, + avatarImage: spaceCreationPostProcessService.avatarImage, tasks: tasks, isFinished: tasks.first?.state == .failure || tasks.reduce(true, { result, task in result && task.isFinished }), errorCount: tasks.reduce(0, { result, task in result + (task.state == .failure ? 1 : 0) }) diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Coordinator/SpaceCreationRoomsCoordinator.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Coordinator/SpaceCreationRoomsCoordinator.swift index 6d48d496e..6826dd2bc 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Coordinator/SpaceCreationRoomsCoordinator.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Coordinator/SpaceCreationRoomsCoordinator.swift @@ -46,11 +46,12 @@ final class SpaceCreationRoomsCoordinator: Coordinator, Presentable { .addDependency(AvatarService.instantiate(mediaManager: parameters.session.mediaManager)) spaceCreationRoomsViewModel = viewModel let hostingController = VectorHostingController(rootView: view) - hostingController.hidesBackTitleWhenPushed = true + hostingController.isNavigationBarHidden = true spaceCreationRoomsHostingController = hostingController } // MARK: - Public + func start() { MXLog.debug("[SpaceCreationRoomsCoordinator] did start.") spaceCreationRoomsViewModel.callback = { [weak self] result in @@ -59,6 +60,8 @@ final class SpaceCreationRoomsCoordinator: Coordinator, Presentable { switch result { case .cancel: self.callback?(.cancel) + case .back: + self.callback?(.back) case .done: self.callback?(.didSetupRooms) } diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Model/SpaceCreationRoomsCoordinatorAction.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Model/SpaceCreationRoomsCoordinatorAction.swift index f851c2085..749d87bd1 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Model/SpaceCreationRoomsCoordinatorAction.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Model/SpaceCreationRoomsCoordinatorAction.swift @@ -18,5 +18,6 @@ import Foundation enum SpaceCreationRoomsCoordinatorAction { case cancel + case back case didSetupRooms } diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Model/SpaceCreationRoomsViewAction.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Model/SpaceCreationRoomsViewAction.swift index 752dd768e..c7ed6ce24 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Model/SpaceCreationRoomsViewAction.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Model/SpaceCreationRoomsViewAction.swift @@ -20,5 +20,6 @@ import Foundation enum SpaceCreationRoomsViewAction { case cancel + case back case done } diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Model/SpaceCreationRoomsViewModelResult.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Model/SpaceCreationRoomsViewModelResult.swift index da6697d96..8368baacc 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Model/SpaceCreationRoomsViewModelResult.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Model/SpaceCreationRoomsViewModelResult.swift @@ -20,5 +20,6 @@ import Foundation enum SpaceCreationRoomsViewModelResult { case cancel + case back case done } diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/View/SpaceCreationRooms.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/View/SpaceCreationRooms.swift index 8f6581105..2fb0eed8f 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/View/SpaceCreationRooms.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/View/SpaceCreationRooms.swift @@ -33,23 +33,49 @@ struct SpaceCreationRooms: View { var body: some View { VStack { - Text(VectorL10n.spacesCreationNewRoomsTitle) - .multilineTextAlignment(.center) - .font(theme.fonts.title3SB) - .foregroundColor(theme.colors.primaryContent) - Spacer().frame(height: 20) - Text(VectorL10n.spacesCreationNewRoomsMessage) - .multilineTextAlignment(.center) - .font(theme.fonts.body) - .foregroundColor(theme.colors.secondaryContent) + ThemableNavigationBar(title: nil, showBackButton: true) { + viewModel.send(viewAction: .back) + } closeAction: { + viewModel.send(viewAction: .cancel) + } + mainView + } + .background(theme.colors.background) + .navigationBarHidden(true) + } + + // MARK: - Private + + @ViewBuilder + private var mainView: some View { + VStack { GeometryReader { reader in ScrollView { - VStack { - Spacer() - roomNames + ScrollViewReader { scrollViewReader in + VStack(spacing: 20) { + Text(VectorL10n.spacesCreationNewRoomsTitle) + .multilineTextAlignment(.center) + .font(theme.fonts.title3SB) + .foregroundColor(theme.colors.primaryContent) + Text(VectorL10n.spacesCreationNewRoomsMessage) + .multilineTextAlignment(.center) + .font(theme.fonts.body) + .foregroundColor(theme.colors.secondaryContent) + Spacer() + ForEach(viewModel.rooms.indices) { index in + RoundedBorderTextField(title: VectorL10n.spacesCreationNewRoomsRoomNameTitle, placeHolder: viewModel.rooms[index].defaultName, text: $viewModel.rooms[index].name, footerText: .constant(nil), isError: .constant(false), configuration: UIKitTextInputConfiguration( returnKeyType: index < viewModel.rooms.endIndex - 1 ? .next : .done), onEditingChanged: { editing in + if editing { +// scrollViewReader.scrollTo("roomTextField\(viewModel.rooms.count-1)", anchor: .bottom) + } + }) + .accessibility(identifier: "roomTextField") + .id("roomTextField\(index)") + } + } + .padding(.horizontal, 2) + .padding(.bottom) + .frame(minHeight: reader.size.height - 2) } - .padding(.horizontal, 2) - .frame(minHeight: reader.size.height - 2) } } ThemableButton(icon: nil, title: VectorL10n.next) { @@ -58,34 +84,6 @@ struct SpaceCreationRooms: View { } } .padding(EdgeInsets(top: 24, leading: 16, bottom: 24, trailing: 16)) - .background(theme.colors.background) - .navigationTitle(viewModel.viewState.title) - .configureNavigationBar{ - $0.navigationBar.shadowImage = UIImage() - $0.navigationBar.barTintColor = UIColor(theme.colors.background) - $0.navigationBar.tintColor = UIColor(theme.colors.secondaryContent) - } - .toolbar { - ToolbarItem(placement: .primaryAction) { - Button(action: { - viewModel.send(viewAction: .cancel) - }) { - Image(uiImage: Asset.Images.spacesModalClose.image).renderingMode(.template) - } - } - } - } - - // MARK: - Private - - private var roomNames: some View { - VStack { - ForEach(viewModel.rooms.indices) { index in - RoundedBorderTextField(title: VectorL10n.spacesCreationNewRoomsRoomNameTitle, placeHolder: viewModel.rooms[index].defaultName, text: $viewModel.rooms[index].name, footerText: .constant(nil), isError: .constant(false), configuration: UIKitTextInputConfiguration( returnKeyType: index < viewModel.rooms.endIndex - 1 ? .next : .done)) - .accessibility(identifier: "roomTextField") - } - } - .padding(.bottom) } } diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/ViewModel/SpaceCreationRoomsViewModel.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/ViewModel/SpaceCreationRoomsViewModel.swift index 2861075c9..269e12a28 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/ViewModel/SpaceCreationRoomsViewModel.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/ViewModel/SpaceCreationRoomsViewModel.swift @@ -60,6 +60,8 @@ class SpaceCreationRoomsViewModel: SpaceCreationRoomsViewModelType, SpaceCreatio switch viewAction { case .cancel: cancel() + case .back: + back() case .done: done() } @@ -67,12 +69,18 @@ class SpaceCreationRoomsViewModel: SpaceCreationRoomsViewModelType, SpaceCreatio override class func reducer(state: inout SpaceCreationRoomsViewState, action: SpaceCreationRoomsStateAction) { } + + // MARK: - Private private func done() { self.creationParameters.newRooms = self.context.rooms callback?(.done) } + private func back() { + callback?(.back) + } + private func cancel() { callback?(.cancel) } diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Coordinator/SpaceCreationSettingsCoordinator.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Coordinator/SpaceCreationSettingsCoordinator.swift index 283849dfb..ab620514e 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Coordinator/SpaceCreationSettingsCoordinator.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Coordinator/SpaceCreationSettingsCoordinator.swift @@ -53,7 +53,7 @@ final class SpaceCreationSettingsCoordinator: Coordinator, Presentable { .addDependency(AvatarService.instantiate(mediaManager: parameters.session.mediaManager)) spaceCreationSettingsViewModel = viewModel let hostingController = VectorHostingController(rootView: view) - hostingController.hidesBackTitleWhenPushed = true + hostingController.isNavigationBarHidden = true spaceCreationSettingsHostingController = hostingController } @@ -69,6 +69,8 @@ final class SpaceCreationSettingsCoordinator: Coordinator, Presentable { self.callback?(.didSetupParameters) case .cancel: self.callback?(.cancel) + case .back: + self.callback?(.back) case .pickImage(let sourceRect): self.pickImage(from: sourceRect) break diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Model/SpaceCreationSettingsCoordinatorAction.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Model/SpaceCreationSettingsCoordinatorAction.swift index b78818f52..b94d0d3c2 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Model/SpaceCreationSettingsCoordinatorAction.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Model/SpaceCreationSettingsCoordinatorAction.swift @@ -18,5 +18,6 @@ import Foundation enum SpaceCreationSettingsCoordinatorAction { case cancel + case back case didSetupParameters } diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Model/SpaceCreationSettingsViewAction.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Model/SpaceCreationSettingsViewAction.swift index 837e265dd..de2350fc9 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Model/SpaceCreationSettingsViewAction.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Model/SpaceCreationSettingsViewAction.swift @@ -22,6 +22,7 @@ import UIKit /// Actions send from the `View` to the `ViewModel`. enum SpaceCreationSettingsViewAction { case cancel + case back case done case pickImage(_ sourceRect: CGRect) case nameChanged(_ newValue: String) diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Model/SpaceCreationSettingsViewModelAction.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Model/SpaceCreationSettingsViewModelAction.swift index ee2db0655..94b838e38 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Model/SpaceCreationSettingsViewModelAction.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Model/SpaceCreationSettingsViewModelAction.swift @@ -23,5 +23,6 @@ import UIKit enum SpaceCreationSettingsViewModelAction { case done case cancel + case back case pickImage(_ sourceRect: CGRect) } diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/View/SpaceCreationSettings.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/View/SpaceCreationSettings.swift index ff88cd630..bd37b32dd 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/View/SpaceCreationSettings.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/View/SpaceCreationSettings.swift @@ -34,32 +34,29 @@ struct SpaceCreationSettings: View { @ViewBuilder var body: some View { + VStack { + ThemableNavigationBar(title: nil, showBackButton: true) { + viewModel.send(viewAction: .back) + } closeAction: { + viewModel.send(viewAction: .cancel) + } + mainView + } + .background(theme.colors.background) + .navigationBarHidden(true) + } + + // MARK: - Private + + @ViewBuilder + private var mainView: some View { VStack(alignment: .center) { - headerView formView footerView } .padding(EdgeInsets(top: 24, leading: 16, bottom: 24, trailing: 16)) - .background(theme.colors.background) - .navigationTitle(viewModel.viewState.title) - .configureNavigationBar{ - $0.navigationBar.shadowImage = UIImage() - $0.navigationBar.barTintColor = UIColor(theme.colors.background) - $0.navigationBar.tintColor = UIColor(theme.colors.secondaryContent) - } - .toolbar { - ToolbarItem(placement: .primaryAction) { - Button(action: { - viewModel.send(viewAction: .cancel) - }) { - Image(uiImage: Asset.Images.spacesModalClose.image).renderingMode(.template) - } - } - } } - // MARK: - Private - @ViewBuilder private var headerView: some View { VStack(alignment: .center, spacing: nil) { @@ -71,66 +68,76 @@ struct SpaceCreationSettings: View { @ViewBuilder private var avatarView: some View { ZStack(alignment: .bottomTrailing) { + GeometryReader { reader in ZStack { - GeometryReader { reader in SpaceAvatarImage(mxContentUri: viewModel.viewState.avatar.mxContentUri, matrixItemId: viewModel.viewState.avatar.matrixItemId, displayName: viewModel.viewState.avatar.displayName, size: .xxLarge) - .gesture(TapGesture().onEnded { _ in - viewModel.send(viewAction: .pickImage(reader.frame(in: .global))) - }) - } .padding(6) if let image = viewModel.viewState.avatarImage { Image(uiImage: image) .resizable() + .scaledToFill() .frame(width: 80, height: 80, alignment: .center) .clipShape(RoundedRectangle(cornerRadius: 8)) - .aspectRatio(contentMode: .fill) } }.padding(10) - Image(systemName: "camera.fill") + .gesture(TapGesture().onEnded { _ in + viewModel.send(viewAction: .pickImage(reader.frame(in: .global))) + }) + } + 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: 112, height: 112) + }.frame(width: 104, height: 104) } @ViewBuilder private var formView: some View { GeometryReader { geometryReader in ScrollView { - ScrollViewReader { value in + ScrollViewReader { scrollViewReader in VStack { - avatarView + headerView Spacer() - VStack(alignment: .leading, spacing: 20) { - RoundedBorderTextField(title: VectorL10n.createRoomPlaceholderName, placeHolder: "", text: $viewModel.roomName, footerText: .constant(viewModel.viewState.roomNameError), isError: .constant(true), configuration: UIKitTextInputConfiguration( returnKeyType: .next)) { newText in - viewModel.send(viewAction: .nameChanged(newText)) + avatarView + Spacer().frame(height:40) + RoundedBorderTextField(title: VectorL10n.createRoomPlaceholderName, placeHolder: "", text: $viewModel.roomName, footerText: .constant(viewModel.viewState.roomNameError), isError: .constant(true), isFirstResponder: true, configuration: UIKitTextInputConfiguration( returnKeyType: .next), onTextChanged: { newText in + viewModel.send(viewAction: .nameChanged(newText)) + }, onEditingChanged: { editing in +// if editing { +// scrollDown(reader: scrollViewReader) +// } + }) + .id("nameTextField") + .padding(.horizontal, 2) + .padding(.bottom, 20) + RoundedBorderTextEditor(title: nil, placeHolder: VectorL10n.spaceTopic, text: $viewModel.topic, textMaxHeight: 72, error: .constant(nil), onTextChanged: { + newText in + viewModel.send(viewAction: .topicChanged(newText)) + }, onEditingChanged: { editing in + if editing { + scrollDown(reader: scrollViewReader) } - RoundedBorderTextEditor(title: nil, placeHolder: VectorL10n.roomDetailsTopic, text: $viewModel.topic, textMaxHeight: 72, error: .constant(nil), onTextChanged: { + }) + .id("topicTextEditor") + .padding(.horizontal, 2) + .padding(.bottom, viewModel.viewState.showRoomAddress ? 20 : 3) + 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: .topicChanged(newText)) + viewModel.send(viewAction: .addressChanged(newText)) }, onEditingChanged: { editing in - if editing { - value.scrollTo("topicTextEditor", anchor: .center) - } +// if editing { +// scrollDown(reader: scrollViewReader) +// } }) - .id("topicTextEditor") - 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)) - }, onEditingChanged: { editing in - if editing { - value.scrollTo("addressTextField", anchor: .bottom) - } - }) - .id("addressTextField") - .accessibility(identifier: "addressTextField") - } + .id("addressTextField") + .accessibility(identifier: "addressTextField") + .padding(.horizontal, 2) + .padding(.bottom, 3) } - .padding(EdgeInsets(top: 0, leading: 2, bottom: 3, trailing: 2)) } .animation(.easeOut(duration: 0.2)) .frame(minHeight: geometryReader.size.height - 2) @@ -146,6 +153,15 @@ struct SpaceCreationSettings: View { viewModel.send(viewAction: .done) } } + + private func scrollDown(reader: ScrollViewProxy) { + let identifier = viewModel.viewState.showRoomAddress ? "addressTextField" : "topicTextEditor" + DispatchQueue.main.async { + withAnimation { + reader.scrollTo(identifier, anchor: .bottom) + } + } + } } // MARK: - Previews diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/ViewModel/SpaceCreationSettingsViewModel.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/ViewModel/SpaceCreationSettingsViewModel.swift index 47c9fe1f6..c05e5d24a 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/ViewModel/SpaceCreationSettingsViewModel.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/ViewModel/SpaceCreationSettingsViewModel.swift @@ -79,6 +79,7 @@ class SpaceCreationSettingsViewModel: SpaceCreationSettingsViewModelType, SpaceC addressMessage: addressMessage(with: validationStatus), isAddressValid: isAddressValid(with: validationStatus), avatar: AvatarInput(mxContentUri: nil, matrixItemId: "", displayName: nil), + avatarImage: creationParameters.userSelectedAvatar, bindings: bindings) } @@ -93,6 +94,8 @@ class SpaceCreationSettingsViewModel: SpaceCreationSettingsViewModelType, SpaceC switch viewAction { case .done: done() + case .back: + back() case .cancel: cancel() case .pickImage(let sourceRect): @@ -151,6 +154,10 @@ class SpaceCreationSettingsViewModel: SpaceCreationSettingsViewModelType, SpaceC callback?(.cancel) } + private func back() { + callback?(.back) + } + private func pickImage(from sourceRect: CGRect) { callback?(.pickImage(sourceRect)) } From 72ba8e671951ae2965e173aa433ab5529d7309d2 Mon Sep 17 00:00:00 2001 From: Gil Eluard Date: Wed, 8 Dec 2021 23:21:55 +0100 Subject: [PATCH 004/165] Invite to Space in room landing #5225 - Added participant invite coordinator --- .../Members/RoomParticipantsViewController.h | 5 + .../Members/RoomParticipantsViewController.m | 225 ++------------ .../ContactsPickerCoordinator.swift | 137 +++++++++ .../ContactsPickerCoordinatorType.swift | 27 ++ .../ContactsPickerViewModel.swift | 280 ++++++++++++++++++ .../ContactsPickerViewModelType.swift | 33 +++ ...nviteModalCoordinatorBridgePresenter.swift | 136 +++++++++ Riot/Modules/Room/RoomCoordinator.swift | 2 + .../Room/RoomCoordinatorBridgePresenter.swift | 15 +- .../Room/RoomCoordinatorParameters.swift | 11 +- .../Room/RoomInfo/RoomInfoCoordinator.swift | 3 + .../RoomInfoCoordinatorParameters.swift | 8 +- Riot/Modules/Room/RoomViewController.h | 5 + Riot/Modules/Room/RoomViewController.m | 27 +- .../ExploreRoomCoordinator.swift | 5 + Riot/Modules/TabBar/TabBarCoordinator.swift | 8 +- Riot/Routers/NavigationRouterStore.swift | 6 +- Riot/SupportingFiles/Riot-Bridging-Header.h | 1 + 18 files changed, 712 insertions(+), 222 deletions(-) create mode 100644 Riot/Modules/Room/ParticipantsInviteModal/ContactsPickerCoordinator.swift create mode 100644 Riot/Modules/Room/ParticipantsInviteModal/ContactsPickerCoordinatorType.swift create mode 100644 Riot/Modules/Room/ParticipantsInviteModal/ContactsPickerViewModel.swift create mode 100644 Riot/Modules/Room/ParticipantsInviteModal/ContactsPickerViewModelType.swift create mode 100644 Riot/Modules/Room/ParticipantsInviteModal/RoomParticipantsInviteModalCoordinatorBridgePresenter.swift diff --git a/Riot/Modules/Room/Members/RoomParticipantsViewController.h b/Riot/Modules/Room/Members/RoomParticipantsViewController.h index 757bb2fca..9f04901b7 100644 --- a/Riot/Modules/Room/Members/RoomParticipantsViewController.h +++ b/Riot/Modules/Room/Members/RoomParticipantsViewController.h @@ -79,6 +79,11 @@ */ @property (nonatomic) MXRoom *mxRoom; +/** + The ID of the parent space. `nil` for home space + */ +@property (nonatomic) NSString *parentSpaceId; + /** Enable mention option in member details view. NO by default */ diff --git a/Riot/Modules/Room/Members/RoomParticipantsViewController.m b/Riot/Modules/Room/Members/RoomParticipantsViewController.m index 1776b8775..5761109c7 100644 --- a/Riot/Modules/Room/Members/RoomParticipantsViewController.m +++ b/Riot/Modules/Room/Members/RoomParticipantsViewController.m @@ -29,7 +29,7 @@ #import "RageShakeManager.h" -@interface RoomParticipantsViewController () +@interface RoomParticipantsViewController () { // Search result NSString *currentSearchText; @@ -49,12 +49,13 @@ id roomDidFlushDataNotificationObserver; RoomMemberDetailsViewController *memberDetailsViewController; - ContactsTableViewController *contactsPickerViewController; UIAlertController *currentAlert; // Observe kThemeServiceDidChangeThemeNotification to handle user interface theme change. id kThemeServiceDidChangeThemeNotificationObserver; + + RoomParticipantsInviteCoordinatorBridgePresenter *invitePresenter; } @end @@ -262,12 +263,6 @@ [memberDetailsViewController destroy]; memberDetailsViewController = nil; } - - if (contactsPickerViewController) - { - [contactsPickerViewController destroy]; - contactsPickerViewController = nil; - } } - (void)viewWillDisappear:(BOOL)animated @@ -542,50 +537,9 @@ - (void)onAddParticipantButtonPressed { - // Push the contacts picker. - contactsPickerViewController = [ContactsTableViewController contactsTableViewController]; - - // Set delegate to handle action on member (start chat, mention) - contactsPickerViewController.contactsTableViewControllerDelegate = self; - - // Prepare its data source - ContactsDataSource *contactsDataSource = [[ContactsDataSource alloc] initWithMatrixSession:self.mxRoom.mxSession]; - contactsDataSource.areSectionsShrinkable = YES; - contactsDataSource.displaySearchInputInContactsList = YES; - contactsDataSource.forceMatrixIdInDisplayName = YES; - // Add a plus icon to the contact cell in the contacts picker, in order to make it more understandable for the end user. - contactsDataSource.contactCellAccessoryImage = [[UIImage imageNamed:@"plus_icon"] vc_tintedImageUsingColor:ThemeService.shared.theme.textPrimaryColor]; - - // List all the participants matrix user id to ignore them during the contacts search. - for (Contact *contact in actualParticipants) - { - contactsDataSource.ignoredContactsByMatrixId[contact.mxMember.userId] = contact; - } - for (Contact *contact in invitedParticipants) - { - if (contact.mxMember) - { - contactsDataSource.ignoredContactsByMatrixId[contact.mxMember.userId] = contact; - } - } - if (userParticipant) - { - contactsDataSource.ignoredContactsByMatrixId[userParticipant.mxMember.userId] = userParticipant; - } - - [contactsPickerViewController showSearch:YES]; - contactsPickerViewController.searchBar.placeholder = [VectorL10n roomParticipantsInviteAnotherUser]; - - // Apply the search pattern if any - if (currentSearchText) - { - contactsPickerViewController.searchBar.text = currentSearchText; - [contactsDataSource searchWithPattern:currentSearchText forceReset:YES]; - } - - [contactsPickerViewController displayList:contactsDataSource]; - - [self pushViewController:contactsPickerViewController]; + self->invitePresenter = [[RoomParticipantsInviteCoordinatorBridgePresenter alloc] initWithSession:self.mxRoom.mxSession room:self.mxRoom parentSpaceId:self.parentSpaceId currentSearchText:currentSearchText actualParticipants:actualParticipants invitedParticipants:invitedParticipants userParticipant:userParticipant]; + self->invitePresenter.delegate = self; + [self->invitePresenter presentFrom:self animated:true]; } - (void)refreshParticipantsFromRoomMembers @@ -1260,13 +1214,6 @@ } } -#pragma mark - ContactsTableViewControllerDelegate - -- (void)contactsTableViewController:(ContactsTableViewController *)contactsTableViewController didSelectContact:(MXKContact*)contact -{ - [self didSelectInvitableContact:contact]; -} - #pragma mark - Actions - (void)onDeleteAt:(NSIndexPath*)path @@ -1491,149 +1438,6 @@ [self withdrawViewControllerAnimated:YES completion:nil]; } -#pragma mark - - -- (void)didSelectInvitableContact:(MXKContact*)contact -{ - __weak typeof(self) weakSelf = self; - - if (currentAlert) - { - [currentAlert dismissViewControllerAnimated:NO completion:nil]; - currentAlert = nil; - } - - // Invite ? - NSString *promptMsg = [VectorL10n roomParticipantsInvitePromptMsg:contact.displayName]; - currentAlert = [UIAlertController alertControllerWithTitle:[VectorL10n roomParticipantsInvitePromptTitle] - message:promptMsg - preferredStyle:UIAlertControllerStyleAlert]; - - [currentAlert addAction:[UIAlertAction actionWithTitle:[MatrixKitL10n cancel] - style:UIAlertActionStyleCancel - handler:^(UIAlertAction * action) { - - if (weakSelf) - { - typeof(self) self = weakSelf; - self->currentAlert = nil; - } - - }]]; - - [currentAlert addAction:[UIAlertAction actionWithTitle:[VectorL10n invite] - style:UIAlertActionStyleDefault - handler:^(UIAlertAction * action) { - - if (weakSelf) - { - typeof(self) self = weakSelf; - self->currentAlert = nil; - - NSArray *identifiers = contact.matrixIdentifiers; - NSString *participantId; - - if (identifiers.count) - { - participantId = identifiers.firstObject; - - // Invite this user if a room is defined - [self addPendingActionMask]; - [self.mxRoom inviteUser:participantId success:^{ - - __strong __typeof(weakSelf)self = weakSelf; - [self removePendingActionMask]; - - // Refresh display by removing the contacts picker - [self->contactsPickerViewController withdrawViewControllerAnimated:YES completion:nil]; - - } failure:^(NSError *error) { - - __strong __typeof(weakSelf)self = weakSelf; - [self removePendingActionMask]; - - MXLogDebug(@"[RoomParticipantsVC] Invite %@ failed", participantId); - // Alert user - [[AppDelegate theDelegate] showErrorAsAlert:error]; - }]; - } - else - { - if (contact.emailAddresses.count) - { - // This is a local contact, consider the first email by default. - // TODO: Prompt the user to select the right email. - MXKEmail *email = contact.emailAddresses.firstObject; - participantId = email.emailAddress; - } - else - { - // This is the text filled by the user. - participantId = contact.displayName; - } - - // Is it an email or a Matrix user ID? - if ([MXTools isEmailAddress:participantId]) - { - [self addPendingActionMask]; - [self.mxRoom inviteUserByEmail:participantId success:^{ - - __strong __typeof(weakSelf)self = weakSelf; - [self removePendingActionMask]; - - // Refresh display by removing the contacts picker - [self->contactsPickerViewController withdrawViewControllerAnimated:YES completion:nil]; - - } failure:^(NSError *error) { - - __strong __typeof(weakSelf)self = weakSelf; - [self removePendingActionMask]; - - MXLogDebug(@"[RoomParticipantsVC] Invite be email %@ failed", participantId); - - // Alert user - if ([error.domain isEqualToString:kMXRestClientErrorDomain] - && error.code == MXRestClientErrorMissingIdentityServer) - { - NSString *message = [VectorL10n errorInvite3pidWithNoIdentityServer]; - [[AppDelegate theDelegate] showAlertWithTitle:message message:nil]; - } - else - { - [[AppDelegate theDelegate] showErrorAsAlert:error]; - } - }]; - } - else //if ([MXTools isMatrixUserIdentifier:participantId]) - { - [self addPendingActionMask]; - [self.mxRoom inviteUser:participantId success:^{ - - __strong __typeof(weakSelf)self = weakSelf; - [self removePendingActionMask]; - - // Refresh display by removing the contacts picker - [self->contactsPickerViewController withdrawViewControllerAnimated:YES completion:nil]; - - } failure:^(NSError *error) { - - __strong __typeof(weakSelf)self = weakSelf; - [self removePendingActionMask]; - - MXLogDebug(@"[RoomParticipantsVC] Invite %@ failed", participantId); - // Alert user - [[AppDelegate theDelegate] showErrorAsAlert:error]; - }]; - } - } - } - - }]]; - - [currentAlert mxk_setAccessibilityIdentifier:@"RoomParticipantsVCInviteAlert"]; - [self presentViewController:currentAlert animated:YES completion:nil]; -} - #pragma mark - UISearchBar delegate - (void)refreshSearchBarItemsColor:(UISearchBar *)searchBar @@ -1763,4 +1567,21 @@ [searchBar resignFirstResponder]; } +#pragma mark - RoomParticipantsInviteCoordinatorBridgePresenterDelegate + +- (void)roomParticipantsInviteCoordinatorBridgePresenterDidComplete:(RoomParticipantsInviteCoordinatorBridgePresenter *)coordinatorBridgePresenter +{ + self->invitePresenter = nil; +} + +- (void)roomParticipantsInviteCoordinatorBridgePresenterDidStartLoading:(RoomParticipantsInviteCoordinatorBridgePresenter *)coordinatorBridgePresenter +{ + [self addPendingActionMask]; +} + +- (void)roomParticipantsInviteCoordinatorBridgePresenterDidEndLoading:(RoomParticipantsInviteCoordinatorBridgePresenter *)coordinatorBridgePresenter +{ + [self removePendingActionMask]; +} + @end diff --git a/Riot/Modules/Room/ParticipantsInviteModal/ContactsPickerCoordinator.swift b/Riot/Modules/Room/ParticipantsInviteModal/ContactsPickerCoordinator.swift new file mode 100644 index 000000000..f871d97c8 --- /dev/null +++ b/Riot/Modules/Room/ParticipantsInviteModal/ContactsPickerCoordinator.swift @@ -0,0 +1,137 @@ +// +// 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 + +class ContactsPickerCoordinator: ContactsPickerCoordinatorType { + + private weak var currentAlert: UIAlertController? + + // MARK: - Private + + private let session: MXSession? + private let room: MXRoom? + private let currentSearchText: String? + private var actualParticipants: [Contact]? + private var invitedParticipants: [Contact]? + private var userParticipant: Contact? + + private let navigationRouter: NavigationRouterType + private weak var contactsPickerViewController: ContactsTableViewController? + private var viewModel: ContactsPickerViewModelType? + + // MARK: Public + + internal var childCoordinators: [Coordinator] = [] + weak var delegate: ContactsPickerCoordinatorDelegate? + + // MARK: - Setup + + init(session: MXSession, room: MXRoom, currentSearchText: String?, actualParticipants: [Contact]?, invitedParticipants: [Contact]?, userParticipant: Contact?, navigationRouter: NavigationRouterType? = nil) { + self.session = session + self.room = room + self.currentSearchText = currentSearchText + + self.actualParticipants = actualParticipants + self.invitedParticipants = invitedParticipants + self.userParticipant = userParticipant + + if let navigationRouter = navigationRouter { + self.navigationRouter = navigationRouter + } else { + self.navigationRouter = NavigationRouter(navigationController: RiotNavigationController()) + } + } + + // MARK: - Public methods + + func start() { + guard let room = self.room else { + MXLog.error("[ContactsCoordinator] start: no room") + return + } + + let viewModel = ContactsPickerViewModel(room: room, actualParticipants: self.actualParticipants, invitedParticipants: self.invitedParticipants, userParticipant: self.userParticipant) + viewModel.coordinatorDelegate = self + self.viewModel = viewModel + + guard viewModel.areParticipantsLoaded else { + viewModel.loadParticipants() + return + } + + startWithParticipants() + } + + func toPresentable() -> UIViewController { + return self.navigationRouter.toPresentable() + } + + // MARK: - Private methods + + private func startWithParticipants() { + // Push the contacts picker. + let contactsViewController = ContactsTableViewController() + viewModel?.prepare(contactsViewController: contactsViewController, currentSearchText: currentSearchText) + self.navigationRouter.push(contactsViewController, animated: true) { [weak self] in + guard let self = self else { return } + self.delegate?.contactsPickerCoordinatorDidClose(self) + } + if let navigationController = self.navigationRouter.toPresentable() as? UINavigationController { + navigationController.pushViewController(contactsViewController, animated: true) + } + contactsPickerViewController = contactsViewController + } +} + +// MARK: - ContactsViewModelCoordinatorDelegate + +extension ContactsPickerCoordinator: ContactsPickerViewModelCoordinatorDelegate { + func contactsPickerViewModelDidStartLoading(_ viewModel: ContactsPickerViewModelType) { + delegate?.contactsPickerCoordinatorDidStartLoading(self) + } + + func contactsPickerViewModelDidEndLoading(_ viewModel: ContactsPickerViewModelType) { + delegate?.contactsPickerCoordinatorDidEndLoading(self) + startWithParticipants() + } + + func contactsPickerViewModelDidStartInvite(_ viewModel: ContactsPickerViewModelType) { + contactsPickerViewController?.startActivityIndicator() + } + + func contactsPickerViewModelDidEndInvite(_ viewModel: ContactsPickerViewModelType) { + contactsPickerViewController?.stopActivityIndicator() + contactsPickerViewController?.withdrawViewController(animated: true, completion: { + self.delegate?.contactsPickerCoordinatorDidClose(self) + }) + } + + func contactsPickerViewModel(_ viewModel: ContactsPickerViewModelType, display message: String, title: String, actions: [UIAlertAction]) { + currentAlert?.dismiss(animated: false, completion: nil) + currentAlert = nil + + let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) + for action in actions { + alert.addAction(action) + } + + alert.mxk_setAccessibilityIdentifier("RoomParticipantsVCInviteAlert") + navigationRouter.present(alert, animated: true) + + currentAlert = alert + } +} diff --git a/Riot/Modules/Room/ParticipantsInviteModal/ContactsPickerCoordinatorType.swift b/Riot/Modules/Room/ParticipantsInviteModal/ContactsPickerCoordinatorType.swift new file mode 100644 index 000000000..5a86da91a --- /dev/null +++ b/Riot/Modules/Room/ParticipantsInviteModal/ContactsPickerCoordinatorType.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 ContactsPickerCoordinatorDelegate: AnyObject { + func contactsPickerCoordinatorDidStartLoading(_ coordinator: ContactsPickerCoordinatorType) + func contactsPickerCoordinatorDidEndLoading(_ coordinator: ContactsPickerCoordinatorType) + func contactsPickerCoordinatorDidClose(_ coordinator: ContactsPickerCoordinatorType) +} + +protocol ContactsPickerCoordinatorType: Coordinator, Presentable { + var delegate: ContactsPickerCoordinatorDelegate? { get } +} diff --git a/Riot/Modules/Room/ParticipantsInviteModal/ContactsPickerViewModel.swift b/Riot/Modules/Room/ParticipantsInviteModal/ContactsPickerViewModel.swift new file mode 100644 index 000000000..45592d344 --- /dev/null +++ b/Riot/Modules/Room/ParticipantsInviteModal/ContactsPickerViewModel.swift @@ -0,0 +1,280 @@ +// +// 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 + +class ContactsPickerViewModel: NSObject, ContactsPickerViewModelType { + + private class RoomMembers { + var actualParticipants: [Contact] = [] + var invitedParticipants: [Contact] = [] + var userParticipant: Contact? + } + + // MARK: - Properties + + weak var coordinatorDelegate: ContactsPickerViewModelCoordinatorDelegate? + private(set) var areParticipantsLoaded: Bool = false + + // MARK: - Private + + private let room: MXRoom + private var actualParticipants: [Contact]? + private var invitedParticipants: [Contact]? + private var userParticipant: Contact? + + // MARK: - Setup + + init(room: MXRoom, actualParticipants: [Contact]?, invitedParticipants: [Contact]?, userParticipant: Contact?) { + self.room = room + self.actualParticipants = actualParticipants + self.invitedParticipants = invitedParticipants + self.userParticipant = userParticipant + + areParticipantsLoaded = actualParticipants != nil && invitedParticipants != nil && userParticipant != nil + + super.init() + } + + // MARK: - Public + + func loadParticipants() { + coordinatorDelegate?.contactsPickerViewModelDidStartLoading(self) + + let roomMembers = RoomMembers() + + // Retrieve the current members from the room state + room.state { [weak self] roomState in + guard let self = self else { + return + } + + guard let roomState = roomState, let members = roomState.members.membersWithoutConferenceUser(), let session = self.room.mxSession, let myUserId = session.myUserId, let roomThirdPartyInvites = roomState.thirdPartyInvites else { + self.finalize(participants: roomMembers) + self.coordinatorDelegate?.contactsPickerViewModelDidEndLoading(self) + return + } + + for member in members { + if member.userId == myUserId { + if member.membership == .join || member.membership == .invite { + let displayName = VectorL10n.you + if let participant = Contact(matrixContactWithDisplayName: displayName, andMatrixID: myUserId) { + participant.mxMember = roomState.members.member(withUserId: myUserId) + roomMembers.userParticipant = participant + } + } + } else { + self.handle(roomMember: member, session: session, members: roomMembers) + } + } + + for invite in roomThirdPartyInvites { + self.add(thirdPartyParticipant: invite, roomState: roomState, members: roomMembers) + } + + self.finalize(participants: roomMembers) + } + } + + func prepare(contactsViewController: ContactsTableViewController, currentSearchText: String?) -> Bool { + // Set delegate to handle action on member (start chat, mention) + contactsViewController.contactsTableViewControllerDelegate = self + + // Prepare its data source + guard let contactsDataSource = ContactsDataSource(matrixSession: room.mxSession) else { + MXLog.error("[ContactsPickerViewModel] prepare: failed to instantiate ContactsDataSource") + return false + } + contactsDataSource.areSectionsShrinkable = true + contactsDataSource.displaySearchInputInContactsList = true + contactsDataSource.forceMatrixIdInDisplayName = true + + // Add a plus icon to the contact cell in the contacts picker, in order to make it more understandable for the end user. + contactsDataSource.contactCellAccessoryImage = UIImage(named: "plus_icon")?.vc_tintedImage(usingColor: ThemeService.shared().theme.textPrimaryColor) + + // List all the participants matrix user id to ignore them during the contacts search. + for contact in actualParticipants ?? [] { + if let userId = contact.mxMember.userId { + contactsDataSource.ignoredContactsByMatrixId[userId] = contact + } + } + + for contact in invitedParticipants ?? [] { + if let userId = contact.mxMember?.userId { + contactsDataSource.ignoredContactsByMatrixId[userId] = contact + } + } + + if let userParticipantId = self.userParticipant?.mxMember.userId { + contactsDataSource.ignoredContactsByMatrixId[userParticipantId] = userParticipant + } + + contactsViewController.showSearch(true) + contactsViewController.searchBar.placeholder = VectorL10n.roomParticipantsInviteAnotherUser + + // Apply the search pattern if any + if currentSearchText != nil { + contactsViewController.searchBar.text = currentSearchText + contactsDataSource.search(withPattern: currentSearchText, forceReset: true) + } + + contactsViewController.displayList(contactsDataSource) + + return true + } + + // MARK: - Private + + private func handle(roomMember: MXRoomMember, session: MXSession, members: RoomMembers) { + // Add this member after checking his status + guard roomMember.membership == .join || roomMember.membership == .invite else { + return + } + + // Prepare the display name of this member + var displayName = roomMember.displayname + if displayName.isEmptyOrNil { + // Look for the corresponding MXUser in matrix session + if let user = session.user(withUserId: roomMember.userId) { + displayName = user.displayname.isEmptyOrNil ? user.userId : user.displayname + } else { + displayName = roomMember.userId + } + } + + // Create the contact related to this member + if let contact = Contact(matrixContactWithDisplayName: displayName, andMatrixID: roomMember.userId) { + contact.mxMember = roomMember + + if roomMember.membership == .invite { + members.invitedParticipants.append(contact) + } else { + members.actualParticipants.append(contact) + } + } + } + + private func add(thirdPartyParticipant invite: MXRoomThirdPartyInvite, roomState: MXRoomState, members: RoomMembers) { + // If the homeserver has converted the 3pid invite into a room member, do no show it + // If the invite has been revoked (null display name), do not show it too. + guard let displayName = invite.displayname, roomState.member(withThirdPartyInviteToken: invite.token) == nil else { + return + } + + if let contact = Contact(matrixContactWithDisplayName: displayName, andMatrixID: nil) { + contact.isThirdPartyInvite = true + contact.mxThirdPartyInvite = invite + members.invitedParticipants.append(contact) + } + } + + private func finalize(participants roomMembers: RoomMembers) { + self.actualParticipants = roomMembers.actualParticipants + self.invitedParticipants = roomMembers.invitedParticipants + self.userParticipant = roomMembers.userParticipant + self.coordinatorDelegate?.contactsPickerViewModelDidEndLoading(self) + } +} + +// MARK: - ContactsTableViewControllerDelegate +extension ContactsPickerViewModel: ContactsTableViewControllerDelegate { + + func contactsTableViewController(_ contactsTableViewController: ContactsTableViewController!, didSelect contact: MXKContact?) { + guard let contact = contact else { + MXLog.error("[ContactsPickerViewModel] contactsTableViewController: nil contact found") + return + } + + let message = VectorL10n.roomParticipantsInvitePromptMsg(contact.displayName) + + coordinatorDelegate?.contactsPickerViewModel(self, display: message, title: VectorL10n.roomParticipantsInvitePromptTitle, actions: [ + UIAlertAction(title: MatrixKitL10n.cancel, style: .cancel, handler: nil), + UIAlertAction(title: VectorL10n.invite, style: .default, handler: { [weak self] action in + self?.invite(contact: contact) + }) + ]) + } + + private func invite(contact: MXKContact) { + if let identifiers = contact.matrixIdentifiers as? [String], let participantId = identifiers.first { + + // Invite this user if a room is defined + self.coordinatorDelegate?.contactsPickerViewModelDidStartInvite(self) + room.invite(.userId(participantId)) { [weak self] response in + guard let self = self else { return } + + switch response { + case .success: + self.coordinatorDelegate?.contactsPickerViewModelDidEndInvite(self) + case .failure: + MXLog.error("[ContactsPickerViewModel] Failed to invite \(participantId) due to error; \(response.error ?? "nil")") + AppDelegate.theDelegate().showError(asAlert: response.error) + } + } + } else { + let _participantId: String? + + if let emailAddresses = contact.emailAddresses as? [MXKEmail], let email = emailAddresses.first { + // This is a local contact, consider the first email by default. + // TODO: Prompt the user to select the right email. + _participantId = email.emailAddress + } else { + // This is the text filled by the user. + _participantId = contact.displayName + } + + guard let participantId = _participantId else { + MXLog.error("[ContactsPickerViewModel] invite: unexpectedly found participantId nil") + return + } + + self.coordinatorDelegate?.contactsPickerViewModelDidStartInvite(self) + // Is it an email or a Matrix user ID? + if MXTools.isEmailAddress(participantId) { + room.invite(.email(participantId)) { [weak self] response in + guard let self = self else { return } + + switch response { + case .success: + self.coordinatorDelegate?.contactsPickerViewModelDidEndInvite(self) + case .failure: + MXLog.error("[ContactsPickerViewModel] Failed to invite \(participantId) by email due to error; \(response.error ?? "nil")") + + if let error = response.error as NSError?, error.domain == kMXRestClientErrorDomain, error.code == MXRestClientErrorMissingIdentityServer { + AppDelegate.theDelegate().showAlert(withTitle: VectorL10n.errorInvite3pidWithNoIdentityServer, message: nil) + } else { + AppDelegate.theDelegate().showError(asAlert: response.error) + } + } + } + } else { + room.invite(.userId(participantId)) { [weak self] response in + guard let self = self else { return } + + switch response { + case .success: + self.coordinatorDelegate?.contactsPickerViewModelDidEndInvite(self) + case .failure: + MXLog.error("[ContactsPickerViewModel] Failed to invite \(participantId) due to error; \(response.error ?? "nil")") + AppDelegate.theDelegate().showError(asAlert: response.error) + } + } + } + } + } + +} diff --git a/Riot/Modules/Room/ParticipantsInviteModal/ContactsPickerViewModelType.swift b/Riot/Modules/Room/ParticipantsInviteModal/ContactsPickerViewModelType.swift new file mode 100644 index 000000000..999e42c84 --- /dev/null +++ b/Riot/Modules/Room/ParticipantsInviteModal/ContactsPickerViewModelType.swift @@ -0,0 +1,33 @@ +// +// 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 ContactsPickerViewModelCoordinatorDelegate: AnyObject { + func contactsPickerViewModelDidStartLoading(_ viewModel: ContactsPickerViewModelType) + func contactsPickerViewModelDidEndLoading(_ viewModel: ContactsPickerViewModelType) + func contactsPickerViewModelDidStartInvite(_ viewModel: ContactsPickerViewModelType) + func contactsPickerViewModelDidEndInvite(_ viewModel: ContactsPickerViewModelType) + func contactsPickerViewModel(_ viewModel: ContactsPickerViewModelType, display message: String, title: String, actions: [UIAlertAction]) +} + +protocol ContactsPickerViewModelType { + var coordinatorDelegate: ContactsPickerViewModelCoordinatorDelegate? { get set } + var areParticipantsLoaded: Bool { get } + + func loadParticipants() + @discardableResult func prepare(contactsViewController: ContactsTableViewController, currentSearchText: String?) -> Bool +} diff --git a/Riot/Modules/Room/ParticipantsInviteModal/RoomParticipantsInviteModalCoordinatorBridgePresenter.swift b/Riot/Modules/Room/ParticipantsInviteModal/RoomParticipantsInviteModalCoordinatorBridgePresenter.swift new file mode 100644 index 000000000..0da1407bd --- /dev/null +++ b/Riot/Modules/Room/ParticipantsInviteModal/RoomParticipantsInviteModalCoordinatorBridgePresenter.swift @@ -0,0 +1,136 @@ +// +// 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 + +@objc protocol RoomParticipantsInviteCoordinatorBridgePresenterDelegate { + func roomParticipantsInviteCoordinatorBridgePresenterDidStartLoading(_ coordinatorBridgePresenter: RoomParticipantsInviteCoordinatorBridgePresenter) + func roomParticipantsInviteCoordinatorBridgePresenterDidEndLoading(_ coordinatorBridgePresenter: RoomParticipantsInviteCoordinatorBridgePresenter) + func roomParticipantsInviteCoordinatorBridgePresenterDidComplete(_ coordinatorBridgePresenter: RoomParticipantsInviteCoordinatorBridgePresenter) +} + +/// RoomParticipantsInviteCoordinatorBridgePresenter enables to start ContactsPickerCoordinator from a view controller. +/// This bridge is used while waiting for global usage of coordinator pattern. +@objcMembers +final class RoomParticipantsInviteCoordinatorBridgePresenter: NSObject { + // MARK: - Properties + + // MARK: Private + + private let session: MXSession? + private let room: MXRoom? + private let parentSpaceId: String? + private let currentSearchText: String? + private var actualParticipants: [Contact]? + private var invitedParticipants: [Contact]? + private var userParticipant: Contact? + + private weak var contactsPickerViewController: ContactsTableViewController? + private weak var currentAlert: UIAlertController? + private var contactPickerCoordinator: ContactsPickerCoordinator? + + // MARK: Public + + weak var delegate: RoomParticipantsInviteCoordinatorBridgePresenterDelegate? + + // MARK: - Setup + + init(session: MXSession?, room: MXRoom?, parentSpaceId: String?) { + self.session = session + self.room = room + self.parentSpaceId = parentSpaceId + self.currentSearchText = nil + self.actualParticipants = nil + self.invitedParticipants = nil + self.userParticipant = nil + + super.init() + } + + init(session: MXSession?, room: MXRoom?, parentSpaceId: String?, currentSearchText: String? = nil, actualParticipants: [Contact]? = nil, invitedParticipants: [Contact]? = nil, userParticipant: Contact? = nil) { + self.session = session + self.room = room + self.parentSpaceId = parentSpaceId + self.currentSearchText = currentSearchText + self.actualParticipants = actualParticipants + self.invitedParticipants = invitedParticipants + self.userParticipant = userParticipant + + super.init() + } + + func present(from viewController: UIViewController, animated: Bool) { + guard let room = self.room else { + MXLog.error("[RoomParticipantsInviteCoordinatorBridgePresenter] present: nil room found") + return + } + + if let spaceId = self.parentSpaceId, let spaceRoom = session?.spaceService.getSpace(withId: spaceId)?.room { + presentRoomSelector(between: room, and: spaceRoom, from: viewController) + return + } + + pushContactsPicker(for: room, from: viewController) + } + + // MARK: - Private + + private func presentRoomSelector(between room: MXRoom, and spaceRoom: MXRoom, from viewController: UIViewController) { + let alert = UIAlertController(title: VectorL10n.roomIntroCellAddParticipantsAction, message: nil, preferredStyle: .actionSheet) + alert.addAction(UIAlertAction(title: "To \(spaceRoom.displayName ?? "space")", style: .default) { [weak self] action in + self?.pushContactsPicker(for: spaceRoom, from: viewController) + }) + alert.addAction(UIAlertAction(title: "To just this room", style: .destructive) { [weak self] (action) in + self?.pushContactsPicker(for: room, from: viewController) + }) + alert.addAction(UIAlertAction(title: VectorL10n.cancel, style: .cancel, handler: nil)) + viewController.present(alert, animated: true, completion: nil) + } + + private func pushContactsPicker(for room: MXRoom, from viewController: UIViewController) { + guard let session = self.session else { + MXLog.error("[RoomParticipantsInviteCoordinatorBridgePresenter] pushContactsPicker: nil session found") + return + } + + let navigationRouter: NavigationRouterType? + if let navigationController = viewController.navigationController { + navigationRouter = NavigationRouterStore.shared.findNavigationRouter(for: navigationController) ?? NavigationRouter(navigationController: navigationController) + } else { + navigationRouter = nil + } + + let coordinator = ContactsPickerCoordinator(session: session, room: room, currentSearchText: currentSearchText, actualParticipants: actualParticipants, invitedParticipants: invitedParticipants, userParticipant: userParticipant, navigationRouter: navigationRouter) + coordinator.delegate = self + coordinator.start() + + self.contactPickerCoordinator = coordinator + } +} + +extension RoomParticipantsInviteCoordinatorBridgePresenter: ContactsPickerCoordinatorDelegate { + func contactsPickerCoordinatorDidStartLoading(_ coordinator: ContactsPickerCoordinatorType) { + delegate?.roomParticipantsInviteCoordinatorBridgePresenterDidStartLoading(self) + } + + func contactsPickerCoordinatorDidEndLoading(_ coordinator: ContactsPickerCoordinatorType) { + delegate?.roomParticipantsInviteCoordinatorBridgePresenterDidEndLoading(self) + } + + func contactsPickerCoordinatorDidClose(_ coordinator: ContactsPickerCoordinatorType) { + delegate?.roomParticipantsInviteCoordinatorBridgePresenterDidComplete(self) + } +} diff --git a/Riot/Modules/Room/RoomCoordinator.swift b/Riot/Modules/Room/RoomCoordinator.swift index 3a6c803ff..735a47592 100644 --- a/Riot/Modules/Room/RoomCoordinator.swift +++ b/Riot/Modules/Room/RoomCoordinator.swift @@ -77,6 +77,8 @@ final class RoomCoordinator: NSObject, RoomCoordinatorProtocol { self.roomViewController = RoomViewController.instantiate() self.activityIndicatorPresenter = ActivityIndicatorPresenter() + self.roomViewController.parentSpaceId = parameters.parentSpaceId + super.init() } diff --git a/Riot/Modules/Room/RoomCoordinatorBridgePresenter.swift b/Riot/Modules/Room/RoomCoordinatorBridgePresenter.swift index d6ec2aa4e..8bc5a80f6 100644 --- a/Riot/Modules/Room/RoomCoordinatorBridgePresenter.swift +++ b/Riot/Modules/Room/RoomCoordinatorBridgePresenter.swift @@ -31,6 +31,9 @@ class RoomCoordinatorBridgePresenterParameters: NSObject { /// The room identifier let roomId: String + /// The identifier of the parent space. `nil` for home space + let parentSpaceId: String? + /// If not nil, the room will be opened on this event. let eventId: String? @@ -39,10 +42,12 @@ class RoomCoordinatorBridgePresenterParameters: NSObject { init(session: MXSession, roomId: String, + parentSpaceId: String?, eventId: String?, previewData: RoomPreviewData?) { self.session = session self.roomId = roomId + self.parentSpaceId = parentSpaceId self.eventId = eventId self.previewData = previewData } @@ -76,7 +81,7 @@ final class RoomCoordinatorBridgePresenter: NSObject { func present(from viewController: UIViewController, animated: Bool) { - let coordinator = self.createRoomCoordinator() + let coordinator = self.createRoomCoordinator(parentSpaceId: bridgeParameters.parentSpaceId) coordinator.delegate = self let presentable = coordinator.toPresentable() presentable.modalPresentationStyle = .formSheet @@ -90,7 +95,7 @@ final class RoomCoordinatorBridgePresenter: NSObject { let navigationRouter = NavigationRouterStore.shared.navigationRouter(for: navigationController) - let coordinator = self.createRoomCoordinator(with: navigationRouter) + let coordinator = self.createRoomCoordinator(with: navigationRouter, parentSpaceId: bridgeParameters.parentSpaceId) coordinator.delegate = self coordinator.start() // Will trigger view controller push @@ -110,14 +115,14 @@ final class RoomCoordinatorBridgePresenter: NSObject { // MARK: - Private - private func createRoomCoordinator(with navigationRouter: NavigationRouterType = NavigationRouter(navigationController: RiotNavigationController())) -> RoomCoordinator { + private func createRoomCoordinator(with navigationRouter: NavigationRouterType = NavigationRouter(navigationController: RiotNavigationController()), parentSpaceId: String?) -> RoomCoordinator { let coordinatorParameters: RoomCoordinatorParameters if let previewData = self.bridgeParameters.previewData { - coordinatorParameters = RoomCoordinatorParameters(navigationRouter: navigationRouter, previewData: previewData) + coordinatorParameters = RoomCoordinatorParameters(navigationRouter: navigationRouter, parentSpaceId: parentSpaceId, previewData: previewData) } else { - coordinatorParameters = RoomCoordinatorParameters(navigationRouter: navigationRouter, session: self.bridgeParameters.session, roomId: self.bridgeParameters.roomId, eventId: self.bridgeParameters.eventId) + coordinatorParameters = RoomCoordinatorParameters(navigationRouter: navigationRouter, session: self.bridgeParameters.session, parentSpaceId: parentSpaceId, roomId: self.bridgeParameters.roomId, eventId: self.bridgeParameters.eventId) } return RoomCoordinator(parameters: coordinatorParameters) diff --git a/Riot/Modules/Room/RoomCoordinatorParameters.swift b/Riot/Modules/Room/RoomCoordinatorParameters.swift index fbc9f3511..16874e46f 100644 --- a/Riot/Modules/Room/RoomCoordinatorParameters.swift +++ b/Riot/Modules/Room/RoomCoordinatorParameters.swift @@ -34,6 +34,9 @@ struct RoomCoordinatorParameters { /// The room identifier let roomId: String + /// The identifier of the parent space. `nil` for home space + let parentSpaceId: String? + /// If not nil, the room will be opened on this event. let eventId: String? @@ -46,12 +49,14 @@ struct RoomCoordinatorParameters { navigationRouterStore: NavigationRouterStoreProtocol?, session: MXSession, roomId: String, + parentSpaceId: String?, eventId: String?, previewData: RoomPreviewData?) { self.navigationRouter = navigationRouter self.navigationRouterStore = navigationRouterStore self.session = session self.roomId = roomId + self.parentSpaceId = parentSpaceId self.eventId = eventId self.previewData = previewData } @@ -60,17 +65,19 @@ struct RoomCoordinatorParameters { init(navigationRouter: NavigationRouterType? = nil, navigationRouterStore: NavigationRouterStoreProtocol? = nil, session: MXSession, + parentSpaceId: String?, roomId: String, eventId: String? = nil) { - self.init(navigationRouter: navigationRouter, navigationRouterStore: navigationRouterStore, session: session, roomId: roomId, eventId: eventId, previewData: nil) + self.init(navigationRouter: navigationRouter, navigationRouterStore: navigationRouterStore, session: session, roomId: roomId, parentSpaceId: parentSpaceId, eventId: eventId, previewData: nil) } /// Init to present a room preview init(navigationRouter: NavigationRouterType? = nil, navigationRouterStore: NavigationRouterStoreProtocol? = nil, + parentSpaceId: String?, previewData: RoomPreviewData) { - self.init(navigationRouter: navigationRouter, navigationRouterStore: navigationRouterStore, session: previewData.mxSession, roomId: previewData.roomId, eventId: nil, previewData: previewData) + self.init(navigationRouter: navigationRouter, navigationRouterStore: navigationRouterStore, session: previewData.mxSession, roomId: previewData.roomId, parentSpaceId: parentSpaceId, eventId: nil, previewData: previewData) } } diff --git a/Riot/Modules/Room/RoomInfo/RoomInfoCoordinator.swift b/Riot/Modules/Room/RoomInfo/RoomInfoCoordinator.swift index ccfe76a7e..b02e0cee2 100644 --- a/Riot/Modules/Room/RoomInfo/RoomInfoCoordinator.swift +++ b/Riot/Modules/Room/RoomInfo/RoomInfoCoordinator.swift @@ -28,6 +28,7 @@ final class RoomInfoCoordinator: NSObject, RoomInfoCoordinatorType { private let navigationRouter: NavigationRouterType private let session: MXSession private let room: MXRoom + private let parentSpaceId: String? private let initialSection: RoomInfoSection private weak var roomSettingsViewController: RoomSettingsViewController? @@ -38,6 +39,7 @@ final class RoomInfoCoordinator: NSObject, RoomInfoCoordinatorType { participants.finalizeInit() participants.enableMention = true participants.mxRoom = self.room + participants.parentSpaceId = self.parentSpaceId participants.delegate = self let files = RoomFilesViewController() @@ -95,6 +97,7 @@ final class RoomInfoCoordinator: NSObject, RoomInfoCoordinatorType { self.session = parameters.session self.room = parameters.room + self.parentSpaceId = parameters.parentSpaceId self.initialSection = parameters.initialSection } diff --git a/Riot/Modules/Room/RoomInfo/RoomInfoCoordinatorParameters.swift b/Riot/Modules/Room/RoomInfo/RoomInfoCoordinatorParameters.swift index 42a4aab5b..57261016f 100644 --- a/Riot/Modules/Room/RoomInfo/RoomInfoCoordinatorParameters.swift +++ b/Riot/Modules/Room/RoomInfo/RoomInfoCoordinatorParameters.swift @@ -29,16 +29,18 @@ class RoomInfoCoordinatorParameters: NSObject { let session: MXSession let room: MXRoom + let parentSpaceId: String? let initialSection: RoomInfoSection - init(session: MXSession, room: MXRoom, initialSection: RoomInfoSection) { + init(session: MXSession, room: MXRoom, parentSpaceId: String?, initialSection: RoomInfoSection) { self.session = session self.room = room + self.parentSpaceId = parentSpaceId self.initialSection = initialSection super.init() } - convenience init(session: MXSession, room: MXRoom) { - self.init(session: session, room: room, initialSection: .none) + convenience init(session: MXSession, room: MXRoom, parentSpaceId: String?) { + self.init(session: session, room: room, parentSpaceId: parentSpaceId, initialSection: .none) } } diff --git a/Riot/Modules/Room/RoomViewController.h b/Riot/Modules/Room/RoomViewController.h index 4aa3818bf..2c0548684 100644 --- a/Riot/Modules/Room/RoomViewController.h +++ b/Riot/Modules/Room/RoomViewController.h @@ -78,6 +78,11 @@ extern NSNotificationName const RoomGroupCallTileTappedNotification; */ @property (nonatomic) BOOL showMissedDiscussionsBadge; +/** + ID of the parent space. `nil` for home space. + */ +@property (nonatomic, nullable) NSString *parentSpaceId; + /** Display the preview of a room that is unknown for the user. diff --git a/Riot/Modules/Room/RoomViewController.m b/Riot/Modules/Room/RoomViewController.m index 518a7ac2f..fdf9eef6d 100644 --- a/Riot/Modules/Room/RoomViewController.m +++ b/Riot/Modules/Room/RoomViewController.m @@ -139,7 +139,7 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; @interface RoomViewController () + RoomDataSourceDelegate, RoomCreationModalCoordinatorBridgePresenterDelegate, RoomInfoCoordinatorBridgePresenterDelegate, DialpadViewControllerDelegate, RemoveJitsiWidgetViewDelegate, VoiceMessageControllerDelegate, SpaceDetailPresenterDelegate, UserSuggestionCoordinatorBridgeDelegate, RoomParticipantsInviteCoordinatorBridgePresenterDelegate> { // The preview header @@ -244,6 +244,7 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; @property (nonatomic, strong) RoomCreationModalCoordinatorBridgePresenter *roomCreationModalCoordinatorBridgePresenter; @property (nonatomic, strong) RoomInfoCoordinatorBridgePresenter *roomInfoCoordinatorBridgePresenter; @property (nonatomic, strong) CustomSizedPresentationController *customSizedPresentationController; +@property (nonatomic, strong) RoomParticipantsInviteCoordinatorBridgePresenter *participantsInvitePresenter; @property (nonatomic, getter=isActivitiesViewExpanded) BOOL activitiesViewExpanded; @property (nonatomic, getter=isScrollToBottomHidden) BOOL scrollToBottomHidden; @property (nonatomic, getter=isMissedDiscussionsBadgeHidden) BOOL missedDiscussionsBadgeHidden; @@ -1952,7 +1953,9 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; - (void)showAddParticipants { - [self showRoomInfoWithInitialSection:RoomInfoSectionAddParticipants]; + self.participantsInvitePresenter = [[RoomParticipantsInviteCoordinatorBridgePresenter alloc] initWithSession:self.roomDataSource.mxSession room:self.roomDataSource.room parentSpaceId:self.parentSpaceId]; + self.participantsInvitePresenter.delegate = self; + [self.participantsInvitePresenter presentFrom:self animated:YES]; } - (void)showRoomTopicChange @@ -1967,7 +1970,7 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; - (void)showRoomInfoWithInitialSection:(RoomInfoSection)roomInfoSection { - RoomInfoCoordinatorParameters *parameters = [[RoomInfoCoordinatorParameters alloc] initWithSession:self.roomDataSource.mxSession room:self.roomDataSource.room initialSection:roomInfoSection]; + RoomInfoCoordinatorParameters *parameters = [[RoomInfoCoordinatorParameters alloc] initWithSession:self.roomDataSource.mxSession room:self.roomDataSource.room parentSpaceId:self.parentSpaceId initialSection:roomInfoSection]; self.roomInfoCoordinatorBridgePresenter = [[RoomInfoCoordinatorBridgePresenter alloc] initWithParameters:parameters]; @@ -6569,4 +6572,22 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; [self mention:member]; } +#pragma mark - RoomParticipantsInviteCoordinatorBridgePresenterDelegate + +- (void)roomParticipantsInviteCoordinatorBridgePresenterDidComplete:(RoomParticipantsInviteCoordinatorBridgePresenter *)coordinatorBridgePresenter +{ + self.participantsInvitePresenter = nil; +} + +- (void)roomParticipantsInviteCoordinatorBridgePresenterDidStartLoading:(RoomParticipantsInviteCoordinatorBridgePresenter *)coordinatorBridgePresenter +{ + [self startActivityIndicator]; +} + +- (void)roomParticipantsInviteCoordinatorBridgePresenterDidEndLoading:(RoomParticipantsInviteCoordinatorBridgePresenter *)coordinatorBridgePresenter +{ + [self stopActivityIndicator]; +} + @end + diff --git a/Riot/Modules/Spaces/SpaceRoomList/ExploreRoomCoordinator.swift b/Riot/Modules/Spaces/SpaceRoomList/ExploreRoomCoordinator.swift index e90ea7160..93f43a6e2 100644 --- a/Riot/Modules/Spaces/SpaceRoomList/ExploreRoomCoordinator.swift +++ b/Riot/Modules/Spaces/SpaceRoomList/ExploreRoomCoordinator.swift @@ -28,6 +28,7 @@ final class ExploreRoomCoordinator: ExploreRoomCoordinatorType { private let navigationRouter: NavigationRouterType private let session: MXSession private let spaceId: String + private var spaceIdStack: [String] private weak var roomDetailCoordinator: SpaceChildRoomDetailCoordinator? private lazy var slidingModalPresenter: SlidingModalPresenter = { @@ -47,6 +48,7 @@ final class ExploreRoomCoordinator: ExploreRoomCoordinatorType { self.navigationRouter = NavigationRouter(navigationController: RiotNavigationController()) self.session = session self.spaceId = spaceId + self.spaceIdStack = [spaceId] } // MARK: - Public methods @@ -72,8 +74,10 @@ final class ExploreRoomCoordinator: ExploreRoomCoordinatorType { let coordinator = self.createShowSpaceExploreRoomCoordinator(session: self.session, spaceId: item.childInfo.childRoomId, spaceName: item.childInfo.name) coordinator.start() self.add(childCoordinator: coordinator) + self.spaceIdStack.append(item.childInfo.childRoomId) self.navigationRouter.push(coordinator.toPresentable(), animated: true) { self.remove(childCoordinator: coordinator) + self.spaceIdStack.removeLast() } } @@ -134,6 +138,7 @@ final class ExploreRoomCoordinator: ExploreRoomCoordinatorType { } self?.navigationRouter.push(roomViewController, animated: true, popCompletion: nil) + roomViewController.parentSpaceId = self?.spaceIdStack.last roomViewController.displayRoom(roomDataSource) roomViewController.navigationItem.leftItemsSupplementBackButton = true roomViewController.showMissedDiscussionsBadge = false diff --git a/Riot/Modules/TabBar/TabBarCoordinator.swift b/Riot/Modules/TabBar/TabBarCoordinator.swift index 63bf354a2..b2543935a 100644 --- a/Riot/Modules/TabBar/TabBarCoordinator.swift +++ b/Riot/Modules/TabBar/TabBarCoordinator.swift @@ -394,7 +394,7 @@ final class TabBarCoordinator: NSObject, TabBarCoordinatorType { let roomCoordinatorParameters = RoomCoordinatorParameters(navigationRouterStore: NavigationRouterStore.shared, session: roomNavigationParameters.mxSession, - roomId: roomNavigationParameters.roomId, + parentSpaceId: self.currentSpaceId, roomId: roomNavigationParameters.roomId, eventId: roomNavigationParameters.eventId) self.showRoom(with: roomCoordinatorParameters, @@ -407,7 +407,7 @@ final class TabBarCoordinator: NSObject, TabBarCoordinatorType { // RoomCoordinator will be presented by the split view. // As we don't know which navigation controller instance will be used, // give the NavigationRouterStore instance and let it find the associated navigation controller - let roomCoordinatorParameters = RoomCoordinatorParameters(navigationRouterStore: NavigationRouterStore.shared, session: matrixSession, roomId: roomId, eventId: eventId) + let roomCoordinatorParameters = RoomCoordinatorParameters(navigationRouterStore: NavigationRouterStore.shared, session: matrixSession, parentSpaceId: self.currentSpaceId, roomId: roomId, eventId: eventId) self.showRoom(with: roomCoordinatorParameters, completion: completion) } @@ -417,7 +417,7 @@ final class TabBarCoordinator: NSObject, TabBarCoordinatorType { // RoomCoordinator will be presented by the split view // We don't which navigation controller instance will be used // Give the NavigationRouterStore instance and let it find the associated navigation controller if needed - let roomCoordinatorParameters = RoomCoordinatorParameters(navigationRouterStore: NavigationRouterStore.shared, previewData: previewData) + let roomCoordinatorParameters = RoomCoordinatorParameters(navigationRouterStore: NavigationRouterStore.shared, parentSpaceId: self.currentSpaceId, previewData: previewData) self.showRoom(with: roomCoordinatorParameters) } @@ -425,7 +425,7 @@ final class TabBarCoordinator: NSObject, TabBarCoordinatorType { private func showRoomPreview(withNavigationParameters roomPreviewNavigationParameters: RoomPreviewNavigationParameters, completion: (() -> Void)?) { let roomCoordinatorParameters = RoomCoordinatorParameters(navigationRouterStore: NavigationRouterStore.shared, - previewData: roomPreviewNavigationParameters.previewData) + parentSpaceId: self.currentSpaceId, previewData: roomPreviewNavigationParameters.previewData) self.showRoom(with: roomCoordinatorParameters, stackOnSplitViewDetail: roomPreviewNavigationParameters.presentationParameters.stackAboveVisibleViews, diff --git a/Riot/Routers/NavigationRouterStore.swift b/Riot/Routers/NavigationRouterStore.swift index 072798f55..9aaca74c6 100644 --- a/Riot/Routers/NavigationRouterStore.swift +++ b/Riot/Routers/NavigationRouterStore.swift @@ -49,12 +49,12 @@ class NavigationRouterStore: NavigationRouterStoreProtocol { return navigationRouter } - // MARK: - Private - - private func findNavigationRouter(for navigationController: UINavigationController) -> NavigationRouterType? { + func findNavigationRouter(for navigationController: UINavigationController) -> NavigationRouterType? { return self.navigationRouters[navigationController] } + // MARK: - Private + private func removeNavigationRouter(for navigationController: UINavigationController) { self.navigationRouters[navigationController] = nil } diff --git a/Riot/SupportingFiles/Riot-Bridging-Header.h b/Riot/SupportingFiles/Riot-Bridging-Header.h index a53ae6f03..dcd5eb379 100644 --- a/Riot/SupportingFiles/Riot-Bridging-Header.h +++ b/Riot/SupportingFiles/Riot-Bridging-Header.h @@ -45,6 +45,7 @@ #import "RoomInputToolbarView.h" #import "NSArray+Element.h" #import "ShareItemSender.h" +#import "Contact.h" // MatrixKit common imports, shared with all targets #import "MatrixKit-Bridging-Header.h" From 8056f3289cfddc34f8b0544b7b6c51ff89ceda2a Mon Sep 17 00:00:00 2001 From: Gil Eluard Date: Thu, 9 Dec 2021 09:04:21 +0100 Subject: [PATCH 005/165] [iOS] Create public space #143 - Update after design review --- Riot/Assets/en.lproj/Vector.strings | 5 +++-- Riot/Generated/Strings.swift | 8 ++++++-- .../Modules/Common/Util/RoundedBorderTextField.swift | 2 +- .../Modules/Common/Util/ThemableNavigationBar.swift | 1 - .../Coordinator/SpaceCreationCoordinator.swift | 4 ++-- .../Test/UI/SpaceCreationEmailInvitesUITests.swift | 2 ++ .../SpaceCreationEmailInvitesViewModelTests.swift | 2 +- .../View/SpaceCreationEmailInvites.swift | 3 +-- .../View/SpaceCreationMatrixItemChooserListRow.swift | 2 +- .../SpaceCreationMenu/View/SpaceCreationMenu.swift | 2 +- .../Model/SpaceCreationPostProcessViewState.swift | 1 + .../Mock/MockSpaceCreationPostProcessService.swift | 1 + .../SpaceCreationPostProcessServiceProtocol.swift | 1 + .../SpaceCreationPostProcessViewModelTests.swift | 12 ++++++------ .../View/SpaceCreationPostProcess.swift | 2 +- .../SpaceCreationRooms/View/SpaceCreationRooms.swift | 2 +- 16 files changed, 29 insertions(+), 21 deletions(-) diff --git a/Riot/Assets/en.lproj/Vector.strings b/Riot/Assets/en.lproj/Vector.strings index 15ec80e3c..3c0e832cf 100644 --- a/Riot/Assets/en.lproj/Vector.strings +++ b/Riot/Assets/en.lproj/Vector.strings @@ -67,6 +67,7 @@ "done" = "Done"; "private" = "Private"; "public" = "Public"; +"stop" = "Stop"; // Call Bar "callbar_only_single_active" = "Tap to return to the call (%@)"; @@ -1761,8 +1762,8 @@ Tap the + to start adding people."; "spaces_creation_address_already_exists" = "%@\nalready exists"; "spaces_creation_public_space_title" = "Your public space"; "spaces_creation_private_space_title" = "Your private space"; -"spaces_creation_cancel_title" = "Please confirm"; -"spaces_creation_cancel_message" = "You will need to start from scratch next time."; +"spaces_creation_cancel_title" = "Stop creating a space?"; +"spaces_creation_cancel_message" = "Your progress will be lost."; "spaces_creation_new_rooms_title" = "What are some discussions you’ll have?"; "spaces_creation_new_rooms_message" = "We’ll create a room for each one."; diff --git a/Riot/Generated/Strings.swift b/Riot/Generated/Strings.swift index d85a27c3e..ef2d2a861 100644 --- a/Riot/Generated/Strings.swift +++ b/Riot/Generated/Strings.swift @@ -5039,11 +5039,11 @@ public class VectorL10n: NSObject { public static func spacesCreationAddressInvalidCharacters(_ p1: String) -> String { return VectorL10n.tr("Vector", "spaces_creation_address_invalid_characters", p1) } - /// You will need to start from scratch next time. + /// Your progress will be lost. public static var spacesCreationCancelMessage: String { return VectorL10n.tr("Vector", "spaces_creation_cancel_message") } - /// Please confirm + /// Stop creating a space? public static var spacesCreationCancelTitle: String { return VectorL10n.tr("Vector", "spaces_creation_cancel_title") } @@ -5239,6 +5239,10 @@ public class VectorL10n: NSObject { public static var start: String { return VectorL10n.tr("Vector", "start") } + /// Stop + public static var stop: String { + return VectorL10n.tr("Vector", "stop") + } /// Element is a new type of messenger and collaboration app that:\n\n1. Puts you in control to preserve your privacy\n2. Lets you communicate with anyone in the Matrix network, and even beyond by integrating with apps such as Slack\n3. Protects you from advertising, datamining, backdoors and walled gardens\n4. Secures you through end-to-end encryption, with cross-signing to verify others\n\nElement is completely different from other messaging and collaboration apps because it is decentralised and open source.\n\nElement lets you self-host - or choose a host - so that you have privacy, ownership and control of your data and conversations. It gives you access to an open network; so you’re not just stuck speaking to other Element users only. And it is very secure.\n\nElement is able to do all this because it operates on Matrix - the standard for open, decentralised communication. \n\nElement puts you in control by letting you choose who hosts your conversations. From the Element app, you can choose to host in different ways:\n\n1. Get a free account on the matrix.org public server\n2. Self-host your account by running a server on your own hardware\n3. Sign up for an account on a custom server by simply subscribing to the Element Matrix Services hosting platform\n\nWhy choose Element?\n\nOWN YOUR DATA: You decide where to keep your data and messages. You own it and control it, not some MEGACORP that mines your data or gives access to third parties.\n\nOPEN MESSAGING AND COLLABORATION: You can chat with anyone else in the Matrix network, whether they’re using Element or another Matrix app, and even if they are using a different messaging system of the likes of Slack, IRC or XMPP.\n\nSUPER-SECURE: Real end-to-end encryption (only those in the conversation can decrypt messages), and cross-signing to verify the devices of conversation participants.\n\nCOMPLETE COMMUNICATION: Messaging, voice and video calls, file sharing, screen sharing and a whole bunch of integrations, bots and widgets. Build rooms, communities, stay in touch and get things done.\n\nEVERYWHERE YOU ARE: Stay in touch wherever you are with fully synchronised message history across all your devices and on the web at https://element.io/app. public static var storeFullDescription: String { return VectorL10n.tr("Vector", "store_full_description") diff --git a/RiotSwiftUI/Modules/Common/Util/RoundedBorderTextField.swift b/RiotSwiftUI/Modules/Common/Util/RoundedBorderTextField.swift index 64e38d8c1..6a571ff86 100644 --- a/RiotSwiftUI/Modules/Common/Util/RoundedBorderTextField.swift +++ b/RiotSwiftUI/Modules/Common/Util/RoundedBorderTextField.swift @@ -69,7 +69,7 @@ struct RoundedBorderTextField: View { .frame(height: 30) .modifier(ClearViewModifier(alignment: .center, text: $text)) } - .padding(EdgeInsets(top: 8, leading: 8, bottom: 8, trailing: 0)) + .padding(EdgeInsets(top: 8, leading: 8, bottom: 8, trailing: text.isEmpty ? 8 : 0)) .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)) diff --git a/RiotSwiftUI/Modules/Common/Util/ThemableNavigationBar.swift b/RiotSwiftUI/Modules/Common/Util/ThemableNavigationBar.swift index 0a8b2bd97..010034d18 100644 --- a/RiotSwiftUI/Modules/Common/Util/ThemableNavigationBar.swift +++ b/RiotSwiftUI/Modules/Common/Util/ThemableNavigationBar.swift @@ -57,7 +57,6 @@ struct ThemableNavigationBar: View { .foregroundColor(theme.colors.secondaryContent) } } - .padding(.top, 25) .padding(.horizontal) .frame(height: 44) .background(theme.colors.background) diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/Coordinator/SpaceCreationCoordinator.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/Coordinator/SpaceCreationCoordinator.swift index 4913fab70..f8484c059 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/Coordinator/SpaceCreationCoordinator.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/Coordinator/SpaceCreationCoordinator.swift @@ -257,10 +257,10 @@ final class SpaceCreationCoordinator: Coordinator { private func cancel() { if parameters.creationParameters.isModified { let alert = UIAlertController(title: VectorL10n.spacesCreationCancelTitle, message: VectorL10n.spacesCreationCancelMessage, preferredStyle: .alert) - alert.addAction(UIAlertAction(title: VectorL10n.continue, style: .destructive, handler: { action in + alert.addAction(UIAlertAction(title: VectorL10n.stop, style: .destructive, handler: { action in self.callback?(.cancel) })) - alert.addAction(UIAlertAction(title: VectorL10n.cancel, style: .cancel, handler: nil)) + alert.addAction(UIAlertAction(title: VectorL10n.continue, style: .cancel, handler: nil)) navigationRouter.present(alert, animated: true) } else { self.callback?(.cancel) diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Test/UI/SpaceCreationEmailInvitesUITests.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Test/UI/SpaceCreationEmailInvitesUITests.swift index fb8b66415..c6f1f8426 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Test/UI/SpaceCreationEmailInvitesUITests.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Test/UI/SpaceCreationEmailInvitesUITests.swift @@ -39,6 +39,8 @@ class SpaceCreationEmailInvitesUITests: MockScreenTest { verifyEmailValues() case .emailValidationFailed: verifyEmailValues() + case .loading: + verifyEmailValues() } } diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Test/Unit/SpaceCreationEmailInvitesViewModelTests.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Test/Unit/SpaceCreationEmailInvitesViewModelTests.swift index 9676dcae2..8a53ff89a 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Test/Unit/SpaceCreationEmailInvitesViewModelTests.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Test/Unit/SpaceCreationEmailInvitesViewModelTests.swift @@ -29,7 +29,7 @@ class SpaceCreationEmailInvitesViewModelTests: XCTestCase { var context: SpaceCreationEmailInvitesViewModelType.Context! override func setUpWithError() throws { - service = MockSpaceCreationEmailInvitesService(defaultValidation: true) + service = MockSpaceCreationEmailInvitesService(defaultValidation: true, isLoading: false) viewModel = SpaceCreationEmailInvitesViewModel(creationParameters: creationParameters, service: service) context = viewModel.context } diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/View/SpaceCreationEmailInvites.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/View/SpaceCreationEmailInvites.swift index 0f104a480..b8e36720c 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/View/SpaceCreationEmailInvites.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/View/SpaceCreationEmailInvites.swift @@ -40,7 +40,6 @@ struct SpaceCreationEmailInvites: View { viewModel.send(viewAction: .cancel) } mainView - .frame(width: .infinity, height: .infinity) .animation(.easeInOut(duration: 0.2), value: viewModel.viewState.loading) .modifier(WaitOverlay(isLoading: .constant(viewModel.viewState.loading))) } @@ -65,7 +64,7 @@ struct SpaceCreationEmailInvites: View { } footerView } - .padding(EdgeInsets(top: 24, leading: 16, bottom: 24, trailing: 16)) + .padding(EdgeInsets(top: 0, leading: 16, bottom: 24, trailing: 16)) } @ViewBuilder diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/View/SpaceCreationMatrixItemChooserListRow.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/View/SpaceCreationMatrixItemChooserListRow.swift index 9d70139e6..0144fce20 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/View/SpaceCreationMatrixItemChooserListRow.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/View/SpaceCreationMatrixItemChooserListRow.swift @@ -55,7 +55,7 @@ struct SpaceCreationMatrixItemChooserListRow: View { Image(systemName: "circle").renderingMode(.template).foregroundColor(theme.colors.tertiaryContent) } } - //add to a style + .contentShape(Rectangle()) .padding(.horizontal) .padding(.vertical, 12) .frame(maxWidth: .infinity) diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/View/SpaceCreationMenu.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/View/SpaceCreationMenu.swift index 164cbbaa6..68bc776c7 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/View/SpaceCreationMenu.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/View/SpaceCreationMenu.swift @@ -57,7 +57,7 @@ struct SpaceCreationMenu: View { .frame(minHeight: reader.size.height - 2) } } - .padding(EdgeInsets(top: 24, leading: 16, bottom: 24, trailing: 16)) + .padding(EdgeInsets(top: 0, leading: 16, bottom: 24, trailing: 16)) } .background(theme.colors.background) } diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Model/SpaceCreationPostProcessViewState.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Model/SpaceCreationPostProcessViewState.swift index 22ce4f952..311b2fcba 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Model/SpaceCreationPostProcessViewState.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Model/SpaceCreationPostProcessViewState.swift @@ -17,6 +17,7 @@ // import Foundation +import UIKit struct SpaceCreationPostProcessViewState: BindableState { var avatar: AvatarInput diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Service/Mock/MockSpaceCreationPostProcessService.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Service/Mock/MockSpaceCreationPostProcessService.swift index a2c034f2a..949ff78ea 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Service/Mock/MockSpaceCreationPostProcessService.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Service/Mock/MockSpaceCreationPostProcessService.swift @@ -18,6 +18,7 @@ import Foundation import Combine +import UIKit @available(iOS 14.0, *) class MockSpaceCreationPostProcessService: SpaceCreationPostProcessServiceProtocol { diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Service/SpaceCreationPostProcessServiceProtocol.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Service/SpaceCreationPostProcessServiceProtocol.swift index 6f3f38705..49c81d3d8 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Service/SpaceCreationPostProcessServiceProtocol.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Service/SpaceCreationPostProcessServiceProtocol.swift @@ -18,6 +18,7 @@ import Foundation import Combine +import UIKit @available(iOS 14.0, *) protocol SpaceCreationPostProcessServiceProtocol: AnyObject { diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Test/Unit/SpaceCreationPostProcessViewModelTests.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Test/Unit/SpaceCreationPostProcessViewModelTests.swift index e5eb02e26..16e9dc6f7 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Test/Unit/SpaceCreationPostProcessViewModelTests.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Test/Unit/SpaceCreationPostProcessViewModelTests.swift @@ -29,13 +29,13 @@ class SpaceCreationPostProcessViewModelTests: XCTestCase { var context: SpaceCreationPostProcessViewModelType.Context! override func setUpWithError() throws { - service = MockSpaceCreationPostProcessService(tasks: Constant.defaultTasks) + service = MockSpaceCreationPostProcessService(tasks: MockSpaceCreationPostProcessService.defaultTasks) viewModel = SpaceCreationPostProcessViewModel.makeSpaceCreationPostProcessViewModel(spaceCreationPostProcessService: service) context = viewModel.context } func testInitialState() { - XCTAssertEqual(context.viewState.tasks, Constant.defaultTasks) + XCTAssertEqual(context.viewState.tasks, MockSpaceCreationPostProcessService.defaultTasks) XCTAssertEqual(context.viewState.errorCount, 1) XCTAssertEqual(context.viewState.isFinished, false) } @@ -43,8 +43,8 @@ class SpaceCreationPostProcessViewModelTests: XCTestCase { func testUpateToNextTask() { let tasksPublisher = context.$viewState.map(\.tasks).removeDuplicates() let awaitDeferred = xcAwaitDeferred(tasksPublisher) - service.simulateUpdate(tasks: Constant.nextStepTasks) - XCTAssertEqual(try awaitDeferred(), Constant.nextStepTasks) + service.simulateUpdate(tasks: MockSpaceCreationPostProcessService.nextStepTasks) + XCTAssertEqual(try awaitDeferred(), MockSpaceCreationPostProcessService.nextStepTasks) XCTAssertEqual(context.viewState.errorCount, 2) XCTAssertEqual(context.viewState.isFinished, false) } @@ -52,8 +52,8 @@ class SpaceCreationPostProcessViewModelTests: XCTestCase { func testLastTaskDone() { let tasksPublisher = context.$viewState.map(\.tasks).removeDuplicates() let awaitDeferred = xcAwaitDeferred(tasksPublisher) - service.simulateUpdate(tasks: Constant.lastTaskDone) - XCTAssertEqual(try awaitDeferred(), Constant.lastTaskDone) + service.simulateUpdate(tasks: MockSpaceCreationPostProcessService.lastTaskDoneWithError) + XCTAssertEqual(try awaitDeferred(), MockSpaceCreationPostProcessService.lastTaskDoneWithError) XCTAssertEqual(context.viewState.errorCount, 2) XCTAssertEqual(context.viewState.isFinished, true) } diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/View/SpaceCreationPostProcess.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/View/SpaceCreationPostProcess.swift index 797b31b0e..37b8d47d4 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/View/SpaceCreationPostProcess.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/View/SpaceCreationPostProcess.swift @@ -41,7 +41,7 @@ struct SpaceCreationPostProcess: View { buttonsPanel } .animation(.easeIn(duration: 0.2), value: viewModel.viewState.errorCount) - .padding(EdgeInsets(top: 24, leading: 16, bottom: 24, trailing: 16)) + .padding(EdgeInsets(top: 0, leading: 16, bottom: 24, trailing: 16)) .navigationBarHidden(true) .background(theme.colors.background) .frame(maxHeight: .infinity) diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/View/SpaceCreationRooms.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/View/SpaceCreationRooms.swift index 2fb0eed8f..0a7a1f670 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/View/SpaceCreationRooms.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/View/SpaceCreationRooms.swift @@ -83,7 +83,7 @@ struct SpaceCreationRooms: View { viewModel.send(viewAction: .done) } } - .padding(EdgeInsets(top: 24, leading: 16, bottom: 24, trailing: 16)) + .padding(EdgeInsets(top: 0, leading: 16, bottom: 24, trailing: 16)) } } From 0f3b37e9af8e1b672d08488bef01dc7f087d943f Mon Sep 17 00:00:00 2001 From: Gil Eluard Date: Thu, 9 Dec 2021 16:12:44 +0100 Subject: [PATCH 006/165] Invite to Space in room landing #5225 - add share invite link to contacts screen - Fixed regression --- Riot/Assets/en.lproj/Vector.strings | 6 ++ Riot/Generated/Strings.swift | 12 +++ .../Recents/DataSources/RecentsDataSource.m | 1 + .../ContactsPickerCoordinator.swift | 5 +- .../ContactsPickerViewModel.swift | 5 +- .../ContactsPickerViewModelType.swift | 2 +- .../RoomInviteViewController.swift | 59 +++++++++++++ .../ShareInviteLinkHeaderView.swift | 77 ++++++++++++++++ .../ShareInviteLinkHeaderView.xib | 52 +++++++++++ .../ShareInviteLinkPresenter.swift | 87 +++++++++++++++++++ 10 files changed, 299 insertions(+), 7 deletions(-) create mode 100644 Riot/Modules/Room/ParticipantsInviteModal/RoomInviteViewController.swift create mode 100644 Riot/Modules/Room/ParticipantsInviteModal/ShareInviteLinkHeaderView.swift create mode 100644 Riot/Modules/Room/ParticipantsInviteModal/ShareInviteLinkHeaderView.xib create mode 100644 Riot/Modules/Room/ParticipantsInviteModal/ShareInviteLinkPresenter.swift diff --git a/Riot/Assets/en.lproj/Vector.strings b/Riot/Assets/en.lproj/Vector.strings index c8b57ab05..5a2725a00 100644 --- a/Riot/Assets/en.lproj/Vector.strings +++ b/Riot/Assets/en.lproj/Vector.strings @@ -1680,6 +1680,12 @@ Tap the + to start adding people."; "invite_friends_action" = "Invite friends to %@"; "invite_friends_share_text" = "Hey, talk to me on %@: %@"; +// MARK: - Share invite link + +"share_invite_link_action" = "Share invite link"; +"share_invite_link_room_text" = "Hey, join this room on %@"; +"share_invite_link_space_text" = "Hey, join this space on %@"; + // Mark: - Room avatar view "room_avatar_view_accessibility_label" = "avatar"; diff --git a/Riot/Generated/Strings.swift b/Riot/Generated/Strings.swift index 3ddc20669..679e3943d 100644 --- a/Riot/Generated/Strings.swift +++ b/Riot/Generated/Strings.swift @@ -4815,6 +4815,18 @@ public class VectorL10n: NSObject { public static var shareExtensionSendNow: String { return VectorL10n.tr("Vector", "share_extension_send_now") } + /// Share invite link + public static var shareInviteLinkAction: String { + return VectorL10n.tr("Vector", "share_invite_link_action") + } + /// Hey, join this room on %@ + public static func shareInviteLinkRoomText(_ p1: String) -> String { + return VectorL10n.tr("Vector", "share_invite_link_room_text", p1) + } + /// Hey, join this space on %@ + public static func shareInviteLinkSpaceText(_ p1: String) -> String { + return VectorL10n.tr("Vector", "share_invite_link_space_text", p1) + } /// Feedback public static var sideMenuActionFeedback: String { return VectorL10n.tr("Vector", "side_menu_action_feedback") diff --git a/Riot/Modules/Common/Recents/DataSources/RecentsDataSource.m b/Riot/Modules/Common/Recents/DataSources/RecentsDataSource.m index 82b5823b2..827716d2d 100644 --- a/Riot/Modules/Common/Recents/DataSources/RecentsDataSource.m +++ b/Riot/Modules/Common/Recents/DataSources/RecentsDataSource.m @@ -195,6 +195,7 @@ NSString *const kRecentsDataSourceTapOnDirectoryServerChange = @"kRecentsDataSou - (void)setCurrentSpace:(MXSpace *)currentSpace { + super.currentSpace = currentSpace; [self.recentsListService updateSpace:currentSpace]; } diff --git a/Riot/Modules/Room/ParticipantsInviteModal/ContactsPickerCoordinator.swift b/Riot/Modules/Room/ParticipantsInviteModal/ContactsPickerCoordinator.swift index f871d97c8..2170beb6d 100644 --- a/Riot/Modules/Room/ParticipantsInviteModal/ContactsPickerCoordinator.swift +++ b/Riot/Modules/Room/ParticipantsInviteModal/ContactsPickerCoordinator.swift @@ -84,15 +84,12 @@ class ContactsPickerCoordinator: ContactsPickerCoordinatorType { private func startWithParticipants() { // Push the contacts picker. - let contactsViewController = ContactsTableViewController() + let contactsViewController = RoomInviteViewController() viewModel?.prepare(contactsViewController: contactsViewController, currentSearchText: currentSearchText) self.navigationRouter.push(contactsViewController, animated: true) { [weak self] in guard let self = self else { return } self.delegate?.contactsPickerCoordinatorDidClose(self) } - if let navigationController = self.navigationRouter.toPresentable() as? UINavigationController { - navigationController.pushViewController(contactsViewController, animated: true) - } contactsPickerViewController = contactsViewController } } diff --git a/Riot/Modules/Room/ParticipantsInviteModal/ContactsPickerViewModel.swift b/Riot/Modules/Room/ParticipantsInviteModal/ContactsPickerViewModel.swift index 45592d344..e0d9b6e31 100644 --- a/Riot/Modules/Room/ParticipantsInviteModal/ContactsPickerViewModel.swift +++ b/Riot/Modules/Room/ParticipantsInviteModal/ContactsPickerViewModel.swift @@ -64,7 +64,6 @@ class ContactsPickerViewModel: NSObject, ContactsPickerViewModelType { guard let roomState = roomState, let members = roomState.members.membersWithoutConferenceUser(), let session = self.room.mxSession, let myUserId = session.myUserId, let roomThirdPartyInvites = roomState.thirdPartyInvites else { self.finalize(participants: roomMembers) - self.coordinatorDelegate?.contactsPickerViewModelDidEndLoading(self) return } @@ -90,7 +89,9 @@ class ContactsPickerViewModel: NSObject, ContactsPickerViewModelType { } } - func prepare(contactsViewController: ContactsTableViewController, currentSearchText: String?) -> Bool { + func prepare(contactsViewController: RoomInviteViewController, currentSearchText: String?) -> Bool { + contactsViewController.room = self.room + // Set delegate to handle action on member (start chat, mention) contactsViewController.contactsTableViewControllerDelegate = self diff --git a/Riot/Modules/Room/ParticipantsInviteModal/ContactsPickerViewModelType.swift b/Riot/Modules/Room/ParticipantsInviteModal/ContactsPickerViewModelType.swift index 999e42c84..92f2d36cb 100644 --- a/Riot/Modules/Room/ParticipantsInviteModal/ContactsPickerViewModelType.swift +++ b/Riot/Modules/Room/ParticipantsInviteModal/ContactsPickerViewModelType.swift @@ -29,5 +29,5 @@ protocol ContactsPickerViewModelType { var areParticipantsLoaded: Bool { get } func loadParticipants() - @discardableResult func prepare(contactsViewController: ContactsTableViewController, currentSearchText: String?) -> Bool + @discardableResult func prepare(contactsViewController: RoomInviteViewController, currentSearchText: String?) -> Bool } diff --git a/Riot/Modules/Room/ParticipantsInviteModal/RoomInviteViewController.swift b/Riot/Modules/Room/ParticipantsInviteModal/RoomInviteViewController.swift new file mode 100644 index 000000000..20979b47b --- /dev/null +++ b/Riot/Modules/Room/ParticipantsInviteModal/RoomInviteViewController.swift @@ -0,0 +1,59 @@ +// +// 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 + +class RoomInviteViewController: ContactsTableViewController { + + var room: MXRoom? + var roomAlias: String? + + private lazy var shareLinkPresenter: ShareInviteLinkPresenter = { + ShareInviteLinkPresenter() + }() + + override func viewDidLoad() { + super.viewDidLoad() + + roomAlias = room?.summary?.aliases?.first + setupShareInviteLinkHeader() + } + + private func setupShareInviteLinkHeader() { + guard roomAlias != nil, RiotSettings.shared.allowInviteExernalUsers else { + contactsTableView.tableHeaderView = nil + return + } + + let inviteHeaderView = ShareInviteLinkHeaderView.instantiate() + inviteHeaderView.delegate = self + contactsTableView.tableHeaderView = inviteHeaderView + } + + private func showInviteLink(from sourceView: UIView?) { + guard let room = room else { + return + } + shareLinkPresenter.present(for: room, from: self, sourceView: sourceView, animated: true) + } +} + +// MARK: - ShareInviteLinkHeaderViewDelegate +extension RoomInviteViewController: ShareInviteLinkHeaderViewDelegate { + func shareInviteLinkHeaderView(_ headerView: ShareInviteLinkHeaderView, didTapButton button: UIButton) { + showInviteLink(from: button) + } +} diff --git a/Riot/Modules/Room/ParticipantsInviteModal/ShareInviteLinkHeaderView.swift b/Riot/Modules/Room/ParticipantsInviteModal/ShareInviteLinkHeaderView.swift new file mode 100644 index 000000000..11d9f5394 --- /dev/null +++ b/Riot/Modules/Room/ParticipantsInviteModal/ShareInviteLinkHeaderView.swift @@ -0,0 +1,77 @@ +// +// Copyright 2020 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 +import Reusable + +@objc +protocol ShareInviteLinkHeaderViewDelegate: AnyObject { + func shareInviteLinkHeaderView(_ headerView: ShareInviteLinkHeaderView, didTapButton button: UIButton) +} + +@objcMembers +final class ShareInviteLinkHeaderView: UIView, NibLoadable, Themable { + + // MARK: - Constants + + private enum Constants { + static let buttonHighlightedAlpha: CGFloat = 0.2 + } + + // MARK: - Properties + + @IBOutlet private weak var button: CustomRoundedButton! + + weak var delegate: ShareInviteLinkHeaderViewDelegate? + + // MARK: - Setup + + static func instantiate() -> ShareInviteLinkHeaderView { + let view = ShareInviteLinkHeaderView.loadFromNib() + view.update(theme: ThemeService.shared().theme) + return view + } + + // MARK: - Life cycle + + override func awakeFromNib() { + super.awakeFromNib() + + button.setTitle(VectorL10n.shareInviteLinkAction, for: .normal) + button.addTarget(self, action: #selector(buttonAction), for: .touchUpInside) + button.layer.cornerRadius = 8 + button.layer.borderWidth = 2 + } + + // MARK: - Public + + func update(theme: Theme) { + button.layer.borderColor = theme.tintColor.cgColor + button.setTitleColor(theme.tintColor, for: .normal) + button.setTitleColor(theme.tintColor.withAlphaComponent(Constants.buttonHighlightedAlpha), for: .highlighted) + button.vc_setBackgroundColor(theme.baseColor, for: .normal) + + let buttonImage = Asset.Images.shareActionButton.image.vc_tintedImage(usingColor: theme.tintColor) + + button.setImage(buttonImage, for: .normal) + } + + // MARK: - Action + + @objc private func buttonAction(_ sender: UIButton) { + delegate?.shareInviteLinkHeaderView(self, didTapButton: button) + } +} diff --git a/Riot/Modules/Room/ParticipantsInviteModal/ShareInviteLinkHeaderView.xib b/Riot/Modules/Room/ParticipantsInviteModal/ShareInviteLinkHeaderView.xib new file mode 100644 index 000000000..546eb5e93 --- /dev/null +++ b/Riot/Modules/Room/ParticipantsInviteModal/ShareInviteLinkHeaderView.xib @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/Room/ParticipantsInviteModal/ShareInviteLinkPresenter.swift b/Riot/Modules/Room/ParticipantsInviteModal/ShareInviteLinkPresenter.swift new file mode 100644 index 000000000..8cbfead9d --- /dev/null +++ b/Riot/Modules/Room/ParticipantsInviteModal/ShareInviteLinkPresenter.swift @@ -0,0 +1,87 @@ +// +// 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 + +/// ShareInviteLinkPresenter enables to share room alias to someone else +@objcMembers +final class ShareInviteLinkPresenter: NSObject { + + // MARK: - Constants + + // MARK: - Properties + + // MARK: Private + + private weak var presentingViewController: UIViewController? + private weak var sourceView: UIView? + + // MARK: - Public + + func present(for room: MXRoom, + from viewController: UIViewController, + sourceView: UIView?, + animated: Bool) { + + self.presentingViewController = viewController + self.sourceView = sourceView + + self.shareInvite(from: room) + } + + func dismiss(animated: Bool, completion: (() -> Void)?) { + self.presentingViewController?.dismiss(animated: animated, completion: completion) + } + + // MARK: - Private + + private func shareInvite(from room: MXRoom) { + + let shareText = self.buildShareText(with: room) + + // Set up activity view controller + let activityItems: [Any] = [ shareText ] + let activityViewController = UIActivityViewController(activityItems: activityItems, applicationActivities: nil) + + self.present(activityViewController, animated: true) + } + + private func buildShareText(with room: MXRoom) -> String { + let roomAliasOrId: String + if let alias = room.summary?.aliases?.first { + roomAliasOrId = alias + } else { + roomAliasOrId = room.matrixItemId + } + + if room.summary?.roomType == .space { + return VectorL10n.shareInviteLinkSpaceText(MXTools.permalink(toRoom: roomAliasOrId)) + } else { + return VectorL10n.shareInviteLinkRoomText(MXTools.permalink(toRoom: roomAliasOrId)) + } + } + + private func present(_ viewController: UIViewController, animated: Bool) { + + // Configure source view when view controller is presented with a popover + if let sourceView = self.sourceView, let popoverPresentationController = viewController.popoverPresentationController { + popoverPresentationController.sourceView = sourceView + popoverPresentationController.sourceRect = sourceView.bounds + } + + self.presentingViewController?.present(viewController, animated: animated, completion: nil) + } +} From de4ed81bbc4b0a060f7d974ef6b50ccbc1602439 Mon Sep 17 00:00:00 2001 From: Gil Eluard Date: Fri, 10 Dec 2021 09:59:10 +0100 Subject: [PATCH 007/165] Implement FAB journeys & rough edge warnings element-ios#5226 - List of Space members with search - Invite interactions - Join room from list - Implement add room button, with rough edge warning. --- .../space_add_room.imageset/Contents.json | 23 +++++ .../space_add_room.png | Bin 0 -> 2342 bytes .../space_add_room@2x.png | Bin 0 -> 4218 bytes .../space_add_room@3x.png | Bin 0 -> 2374 bytes .../space_invite_user.imageset/Contents.json | 26 ++++++ .../space_invite_user.png | Bin 0 -> 496 bytes .../space_invite_user@2x.png | Bin 0 -> 865 bytes .../space_invite_user@3x.png | Bin 0 -> 1194 bytes Riot/Assets/en.lproj/Vector.strings | 3 + Riot/Generated/Images.swift | 2 + Riot/Generated/Strings.swift | 12 +++ Riot/Modules/People/PeopleViewController.m | 3 +- .../Members/RoomParticipantsViewController.h | 3 +- .../Members/RoomParticipantsViewController.m | 13 ++- .../ContactsPickerViewModel.swift | 3 +- .../MemberList/AddItemHeaderView.swift | 88 ++++++++++++++++++ .../MemberList/AddItemHeaderView.xib | 78 ++++++++++++++++ .../SpaceMemberListCoordinator.swift | 4 + .../SpaceMemberListCoordinatorType.swift | 1 + .../SpaceMemberListViewAction.swift | 1 + .../SpaceMemberListViewController.swift | 24 ++++- .../MemberList/SpaceMemberListViewModel.swift | 2 + .../SpaceMemberListViewModelType.swift | 1 + .../SpaceMembersCoordinator.swift | 26 ++++++ .../ExploreRoom/SpaceChildSpaceViewCell.xib | 12 +-- .../ExploreRoom/SpaceChildViewCell.xib | 14 +-- .../SpaceExploreRoomViewController.swift | 28 ++++-- 27 files changed, 335 insertions(+), 32 deletions(-) create mode 100644 Riot/Assets/Images.xcassets/Spaces/space_add_room.imageset/Contents.json create mode 100644 Riot/Assets/Images.xcassets/Spaces/space_add_room.imageset/space_add_room.png create mode 100644 Riot/Assets/Images.xcassets/Spaces/space_add_room.imageset/space_add_room@2x.png create mode 100644 Riot/Assets/Images.xcassets/Spaces/space_add_room.imageset/space_add_room@3x.png create mode 100644 Riot/Assets/Images.xcassets/Spaces/space_invite_user.imageset/Contents.json create mode 100644 Riot/Assets/Images.xcassets/Spaces/space_invite_user.imageset/space_invite_user.png create mode 100644 Riot/Assets/Images.xcassets/Spaces/space_invite_user.imageset/space_invite_user@2x.png create mode 100644 Riot/Assets/Images.xcassets/Spaces/space_invite_user.imageset/space_invite_user@3x.png create mode 100644 Riot/Modules/Spaces/SpaceMembers/MemberList/AddItemHeaderView.swift create mode 100644 Riot/Modules/Spaces/SpaceMembers/MemberList/AddItemHeaderView.xib diff --git a/Riot/Assets/Images.xcassets/Spaces/space_add_room.imageset/Contents.json b/Riot/Assets/Images.xcassets/Spaces/space_add_room.imageset/Contents.json new file mode 100644 index 000000000..cf8bba813 --- /dev/null +++ b/Riot/Assets/Images.xcassets/Spaces/space_add_room.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "space_add_room.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "space_add_room@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "space_add_room@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Riot/Assets/Images.xcassets/Spaces/space_add_room.imageset/space_add_room.png b/Riot/Assets/Images.xcassets/Spaces/space_add_room.imageset/space_add_room.png new file mode 100644 index 0000000000000000000000000000000000000000..451a80229249308719e2e74a8e11ff40f6064684 GIT binary patch literal 2342 zcmZ`*dpuNm8$a%vjdjlsX$%%wGmKBHF$P1-$Sn;Gy3m*z!(22*Go%pG#kFiyE`u&? zOWH_sNjD@(5(c@{+EJm`t1S~{&)_Yu&+a*&-+7+%Jm2s0eV+6C{c(=_c)2M-pb!86 zln8hiU(jkxmZCiP9Oc>1fku|*~7fj|N>Pg&P9nGa0NXG~XV+gg}G7B#eZ^ zmLS{+dpOC{2jaM;bB~EBpQk2@puTH6@ty7 zAuVlfZIKozBno8?BFv*=Slkf4IV;Lwxyb+OxKN_V9C{>|&St?Rbwl>Cqq+8QxJ2md zwcIC{PW`6Didq>991tnVAT1FV$bX_y`1F6FNixf{rMZ^nFp^^E0AETJn-MK(3yZR} z#w=o5AMnhzuc9u%ONoWmRY`dPyeW-(*%LR)`p+WVcuC z{_@dUih^eg3&9}2o>MF&bdg;M0CG5jixUyA#PkzJ?DErg5Df*OBF->Xc%R&+xGUS_3LxDIC%LDn-A31` z)M?w+k#`+K2a*p*it^dEBVP^~Jb31GgmGvjlX#?OC(dMhkav7_LtK^2tyiAjSNSP- z(+cMqU+g2s=@EYkQ)XM6iV6$m8wJian;YI2`An)_&Fio$=ThTQXNQWQgK;OTvvME3 z`2Bv0W@~GU@dtvw=-8he+wRxNNqGKihi&TWUXB5lsFrv@71Q7;xuk6n)Wpx!(B7ZY2c1eX1~q0 zqIFv21n;1IT(7L$34N^m)*4s3N7Ko;>nVw0nC)@M(c4+jeW2dxT)O-*|4UXv8xpcY zCfl91HKsx%b@Hz!#U{P7bMqUD=P8&l*ePw<;g(VtK~nsva8onc;w7%7puE2v!_HN% zmR~eBguENA&(RC#oK>|TN~APDevr4B4m6ltM2*;Fw*W@ZubB;Q5?o_tiEwhe)nvGhl%T#m|3OL?L0H_iC-6Q zPR@7O2hKfIhI=V0wY~mDQx2KNZ+w*HpKdyi>UT6|WDV?A?|vRG76iqOL|+W){z#rH z>~PFvno(BfC`UQ+a7Lo8G<; zQjLL=l~B>m>l&u)sg1h21O`*GPh0MxsI%)dzx2XOpKlA zwKW$1H-&uLujujQgzSvyYQEi(_x+!R%%q&pTKpfis`rM))^se0OU12IJyA)jKa8Ix zls9NJIvQriVt9`0+E^o&>o;sXk+$eT>>^!q`(xJl-RuQjP3+Nq*Cvj(h54aeYl9wB zAIZMqL6YAl@}Ofe6J79~sNTzjSUs(>Q-qh3C|#BLMFgjGcy^lIGv-$+Qx`ciH>GDj xWZ5$o%&e#%Wxy62_U;iu>zrKQIlQo)+4OQ(TD9P@!Gh#ZfUwoer4ko>01&JM0AW!8@SPnBn*;!VHgVb+ z0N@z_AVInH!V+u3s2Cw%xvZ*lU~f({ki;{Eg7e<^0FrhSsh*s<90}1#L?M0C~Ob1v0*_hywTr zgih91IE%r84SlIZu&#!thNc2i01O5rsIG2soY9G2boQIRf(M;WfkPkx0Rb8T+8Vx8 zcZe1Y27_oqAyB9~8=+1M@}c7c)qQA+Ka2cd9U~%*KqXP=Bwru!c3r%SuRmR1L1A0y z@9SrubduXYNY-b=^8k&&*Mk5B2{s(P4^ON>tuAg#I75+Et>_7S6bbgQin_)pE5!s*eM`v2UM1GI` zhTonKjwJ;Wz0Mkuyoo-v?P-p%JNvJRe-jOTy?m)w6g+{5Wb6E({1*B*`iG3||C0GF z@r#InZ147OyZ`fO{fM&97E%BK`TLwA1zfj$vjITpx~Y+YwFh^WoxPWX$=+;O`oMDV zY`b18`7a&;?wg3O*gOT}_H^*>WU-1s6kj+c8SI&rPV#x|Kr~!bIf%8pmrwf8b8h~VL-hPw8?4H`JETneE6iPEYkhHWBFC2OANfY| zj%7<^QdZ@g_Z>$PbSu0wopQd)1|O5z7sXmc2wKp$hQ*&%!Pdxm9YNjvP8V25tJ4yN z>+*O(OOg`^+QQJfu8nnx1mo1vJ1V&HX_h_SV?Jp7EiZ&1smWT(D8X^40^T>$84Sie zG{utKRer>=uQ%j<)mGN7yAmQR_8UAQ2^h!c;6*co&?D)P8HY!OIuFTY+`DdRPE zRV2KSxf~8{j;Edz_`u8X;5{7}BDN~9XX-e?d}!?!ZiMXTS9XsF)=D)LQd3vw=PXU; zmqh-_JrHQ(%ya3B><*_FPan;XXePx_QaYYt`7#=nq~FTF2vbB=`~ z(ZLmxJ(qAg8^F~IIkN**yr+^VxgiV>%L^xLk>z>IhZDW?y>5mZ*|}|P(t^~mw>xc( zA{w~Fay(FQ-s)U#Ua&oZ2mP#K<3vqy!nYVJUQ&u?rkk^mk2yVSA347Lqqs0rsla-& zGjeN3*@Vn2`FPZ~h5Dk>V~1NO>#t^X#PKi1(eG2&rxrizaH@Pk^H$VB7w-E4DdDR6`i>Rf^`MmrYT^ldy?e`?IlA`Z9N|K0tOnF3l?N&C0_CtBlLf_ZRgzzf7FQOx}s$ z265keYGa(=GdkA4R{Dict77!5<RV&hW&iMi}I<&UpTZf`W;_h4h`|1l)vL0*4T@*8M z@PPE|Z$v}J6>HjSX5lP}9a=bN| zsK}M*DJt#%NmMj_?qNpr8LfHurPx1Rm-1Jl6C7uxrzbnH*TX6*p#Z-d=zEtu_O=s4 zZsQ`QRs+@ng}oMz=+nVwXUAn7Dy-@8$dD7+&6sx$ZZFcAE*CjWzIJHLOAtC`Xdt+o zkEAnQYv5#R#&_r2agg4~*hB7ksUewe|I=sE2kA4DN2Cd9_R1iaDCZW=r0gt|CqfwJ z4f3i*i_VdB#$w{-B4pBsLL2JhR{sp2395a?`MkQIu=rNwIR%#)nK#D=8R7)(U8$q*rq#9BYVkZjiG4rd2ek?XT9aDotv9mf6A9Ohx+lu@s2u1 zJS6r3@O|Vtmz{Hr`G)tp;l~e0Ov0CsnANBoz1HxGOzN@>@fYO(%mANq%H%IdjR`}c z@6sFHm>cpc38CjD=y9%ztaJ=&s#fi2(|9bWCbREaOhGJv2!0Sb81-Ew3U@NcD%Wh% zP<7n$={&2mle>!G4I82mxHpyNiiN}o0%-&B?TzwwmX;3u=VNW~!WV42EfC8KjnhoU z=A*$n`FjN@je;rWkX(q6nR(x7H7<&RURwUhPE_-!DL!(*n-t?pZ>+8PcgqJcron1j9raCwyUP8aSgKrxy zJjr{k$^(60W82c;n|$`l)hJLC4A|@C#MAug@z{}typgZV-Kd{=pa_cyi-nPAN;ugF=Kr~@VXAuFq+)ODjwky<{JaWulFR9;E99-z@+ zh2Zh{{mNQ}hMB}a__Pql!Y&H07iA5J-|DQ#@N!&I`~#AT!>ZyD*(K)+_L>HR@;6Xw z(Lt*VvFg|@;vDNSm|XYW<;%YRg0R4}_F*r2Ucmv$mkE<{;*F=ee<;9a_??2Tjo z-|sy!0dhPQIdfd5(Bsc3CEQvEK0*akA_)y1pW)nZGxP#ELZy||vGv{@8q3&VSwcAD;<}P9e7{>UA4E?&# zX?m-<5}~}Z$An2*<%&`%8j^|8LxU1I7pL3z@oyI69PDn2EgC_!T9f1BgWvg6-$c** zDi)AdeQ;S1@`ajiBVsS(N6X)%lTszQOh4eIS~vu+_UYwwJsYEaQ@$B^VENsH=IO8H z4#@A7wYt0^XHa?lK`~B@T_WBrJQ?{8 zV6BRTLc(lUFc6%SX`tPNL`P9kmdvTo#{;D*UoY0Cp7qk5S{gsIZfJMnvJY~i&UKSA z-t#(r=c{Sgn^EQ2ZHA#si8XK zz|Kw2i^&qvaU4#PQq6sP)UI==ZRRGgE>HhR(d}>d&XTkgPJ5TK~mW<1F-SE!ek#hy|l%DoS<@03k>Yjzq zp(fC1P7$zL4dSZ(#T$$J|7_&D>38Y;N+;!k&OtN3`*tg93NU1-QyC%GcEFYb7AFPZ7dZ0(P) zU%A%8ANt)29a`<>f3&9t=)J_h^hGH%NepM*DQT}J}MbH zXx<>M9|S*p+kGzRfSpQuh^H8Ig)lCDn{eUC(m06k6{+`ul`v}Oss2l$#6tjn!eey! zqDu4bYw7k;+1*RFmzM%t#oaXu4h-J zRikWhkfBh1_i#nltKAJFd`EMS7uT2DT{0=spgG>UOfc6r+HbS+IcUE9+J{EBc^jK2 z-b~rv*Y2IN19Q4{rc3G0BM|wUekXSa;Q02Ahcq!xzRb!2OAq^Z#hW||Zd@-jlpSh; zi;v8l<6avT(Gx4qBf#-@o^F;uo;VCv*IB#C)Aq+z^6<2A@$d)#o;J9bqtD^1@F$R1 zoi>?)R|=UlIA}1iMz6kw9#EoG=5RTMwIdT#ExLaq*mPuN);?D=n1m9$w|SWd=8dfO z+z}?EFLVcO8C7;eGC(|=W?6b&cOO)~zb(e2tXbyzb4($J(m6_tXyxkI*r%6FWv;t& zbrsp6%z<{)QIYH=?9P7m)%aXpCP2MlAWb44ez_S&wg2Q)cvrJ%?p-%{S(m3&v7)>R z-!H~lbjs=TnI6Bv6UB4SE2|@!bF;ydTUTTJM4F&o-bD#Zu@-JRTFEcNfiDj=cioj* zR~9)nkdQ-_YnZQO)R%4=e^l9Tu7|a&(L=uzje2#;{<6|n`Au(7xX+N5jaQY#Nb9Cf zoaQ&=x2`aLsEUHyIyT3|g|ri0o^1bJ Nn;M%N6(4tw_z#(gIEDZK literal 0 HcmV?d00001 diff --git a/Riot/Assets/Images.xcassets/Spaces/space_add_room.imageset/space_add_room@3x.png b/Riot/Assets/Images.xcassets/Spaces/space_add_room.imageset/space_add_room@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..de967119af658ba10398610cfeecfe4f42d37b4b GIT binary patch literal 2374 zcmY*bdpy%^8^6b7IaFhXTA8U>i?NECk!6nAFe{Xo9A+kLm_vDFSf%Kw13Aw`4wXZZ z@RVYu5K85cQ_mx(jt?c|*{ZkR_r5>ZeSNR%{$AhfzJ7oGjych6loeJh005NjY$>+D@VLC+jCr1W=qig{E z$=M_E()B`O(wZM$HW%_+F<0gn+csDBHwEige3<&t!CYw-AVm`;Fqp~nMnwk)g@hBM&CuT&LQI>BzF}-GkIe}|Np-z_I1xNEG+Nr|*ZT2J z9^3zSr;zZUV@U?YNpEn5`UbdP+LBU|G)r_0V>2b4rTXTEr0>lC%l+&_!bzL|&trZ} z`aLTd)m(vu`}Nt(6*N4D*c1j;+`2CcD-uJlug^t98?tClnG~yh1T&bYZsqHwhH77`;ajMtsW?zcj6Lk5P>= z+kQLSc#YV_FKV>4E{DS!$VfL{mU!0weaGmd!SX;>b?9q*zZHUAHUG@x|2gP9TA2Yq zjI_Q>5&QavbL7(EY^F}J+#28}iKy7cOo zEZ4f7-`3qdv3J|P^D(T!qz2@~pr>Vut}tN`nUa|L;MQ*UN$r5hCXB`T!>pUT#+$OE zs8(2dP91b`lP8wUb<%6Z^>$+{zF?kJ$T;R`)I}cTgpSM|qV_J(+XF?la!G~-ACX^T z%e+mFg-&9a>o@zD-!|9%V60%NVZ)+2*VSBRehSd)>{O<(v~^+A1Lh}DCB_*&P4Vp5 z$CK9Oi`m_DZPD{Io6fHZ=hr1}h)|nz9uI=f5-<$xvK28iEjsfi0k2>D31xxwBj#?z zcElflUx5f=(6_+n(h6K@B`+Iek!h({-|mT#i8tjFTsG+|l}BVJhz(ZgZZ|w`F{p%k z-sS?J8=x{Q5bryRd5_UvL6Ef_B^?e$G^xX2g71Ne1tr&%Yl$i%iN9SxKca+UIE;}( z;d6y$!*+2~ETtzsrF$QFTOp`Xa@1Iohed_LpZS4e!lFo?D-w>DT?4(%^#$0EGn>ah zMem|LdKwss3WsdHg-wGSsGa(_)^@W}9V2a>I+TV?Jq)R^{ox4pZa_1=GLU_LyT7QLW; zQQ0-Lo7f$C?qO!1<_vc6Dl_=u%D=m4)2$2GvHG_y8uvWX{tzZ%C*PSZ%exx4Zgqo! zgKgu{;+u8tyUlPX8UJ*A&0c(8qk2RqKVmFai{DARJkczsdQcxIitT)2s^KkHX7@ig z{%qOI6&C51Wf6}BH#<6LdAY9@R0rA%QQXrFm|)GN@Um-m#a)|JG!isSEDky5Jn-E- zaNSXFm)Fa)XPeop?j>wDv9K>VDlm`s@#k+}7ycS}`zp3NC(qriQ}!Ieah7xj-GwP#84sd1 zhvvt}sm|4ZuAI=pQt(-05EYwA#9?$-`gQ+>iv)%z=Uw-y5ANTL5JW`TMmgPwyqRa( z$bo1EgX})1wrbstdj|Kj)IT3KIHZxI8_y)e zCl%V$UIN}J;mw2OZlP9aY{`vQ0(2O@ZQLlzXrs}+kMFhCk_l>8#szfbfsPN3 z?1|noSqIt?^+6|1&!(m1>kT(fwKpkZEDDeZ9(rph>ny)8g0Q?bSLDl{zyI-ahN_lQ zV2k_GQ5|xdUhlUBR$N2uT%r!KNqOr_+Y8S$UIcXexD;eWxEL*Yl(uuRtq6WJiWoVl zZiQf@7VcYjwz}kbQsO&qzPWcOXeua4c`+SB=eJ?|Qzo#F8T%&F^U$Bk!#28?0;YDv zwY}VJ+V`bbj!E%n|N*r_4KvL0Q?VzdVNauy|(TMrEB6kfhp8LChphW%(&mSH0!#J9 zmK$s`6raB?)NC+2IMQ0}bsy1LqVBD!y)UM$zxf1PX;xD!5}A9^(RM`ocVK5lqg>sw GFX3OqzRwT< literal 0 HcmV?d00001 diff --git a/Riot/Assets/Images.xcassets/Spaces/space_invite_user.imageset/Contents.json b/Riot/Assets/Images.xcassets/Spaces/space_invite_user.imageset/Contents.json new file mode 100644 index 000000000..d428b7ae6 --- /dev/null +++ b/Riot/Assets/Images.xcassets/Spaces/space_invite_user.imageset/Contents.json @@ -0,0 +1,26 @@ +{ + "images" : [ + { + "filename" : "space_invite_user.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "space_invite_user@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "space_invite_user@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Riot/Assets/Images.xcassets/Spaces/space_invite_user.imageset/space_invite_user.png b/Riot/Assets/Images.xcassets/Spaces/space_invite_user.imageset/space_invite_user.png new file mode 100644 index 0000000000000000000000000000000000000000..672632139f6b632796a6561250fbb6efe3281bd1 GIT binary patch literal 496 zcmVC_rI}#>EOi z8nFlnI3Y6*;t&`vc_lVeyM_is{c{fr(4!pgo!jU2&D@pdX)7`?*TnI3Z{Ic#9`P>_ z+6S_8wNxU{E^B+Dd0%3-U(4PbImg{??Z=%U@M+Cj53 zMV6h*=9I@W)R5*3w>Pp|xa;5aXXGbe%Js86LA%6XA+Tee7`_X~*9RoDUCaZ*%<-X3 mIb<;(GCvE<52f>IvG4`j4S2QA*~>)$0000{`2~whep=UQnVj|0jXaFzwm)XO zCY&Hx_ST^VM2u{J0bhZTaHgLV8Ym_*3qrzxWq=3@1A%AI5$p7X!hr9HLR6t~WH~Jp&9(N5aWGng+EpNg z#DJdWJ<(VKrTT}4f@4oy_gxYU3;A-|cJ@s!g@x+|v-O3?>t6EutLr#qBTr0B%zs9H zi1pH#w>C0tjw?tj#*F*vk=CXhUvP!{hAQPYb0`D)rW1^>;HEJfCLMC;r|bAukbGTS16X4QZL})SO{ZcpaH#MXNn!b!?UoMdG`C_{Kqdp z;lRZlrlkrnA#mSZuB|B!s^}{0qRFr3Zn7 zc*L7G;$L7ay^%L<4~PWByx;RkGM_3)GReG3zUk@dp6&n`42BFr7!amrp16c!McpTa zM*VL2PMCQD1_T!8DgSrW9}-nP7A(xXfe^^6J}9s-h0ZY{4?q4H17ksPF#-i(1PY)p zcdi8#m8R{{}GsOY%sHISA%ANQS;Rw;#)qHt&srGo+&kGyo5 zd|dAw5JEOP{m<@H7y#6+X8A+gonz$7QvLq*<)FDH4hq1A98n3QKp0~~{_S9t-bsS3 z#t~D`i(CYnoPAtGuqsCr$UXQOUijtNT9d%PQ=bc723exj2YENx7Yq$?AO~*PImp)3 zZtwipc=%(K8-?=h!biUO9tQYCm%>=7HXpPn&M6h{yHV!x;ntX%r{+Tv2uj3}GE3e< zNfTk0ur>?gC9{3}k_rnp{m%)p8=_sVn?JU5Hps4PcE0 zelB;~t4YhzVMtXVWhr8GxX9nSsyw)mF{*AaQ7&cK;EeT_^~?7tkIDb7RQB?&A2_{7x?xpBp~vc^kk)`+G$xZP<{ ze2@!047-MFp0^k|90?^*d1hg}hplHHPMF(~dyB$Z5bEvf$_fi`93m1 String { + return VectorL10n.tr("Vector", "room_participants_invite_prompt_to_msg", p1, p2) + } /// INVITED public static var roomParticipantsInvitedSection: String { return VectorL10n.tr("Vector", "room_participants_invited_section") @@ -4987,6 +4991,10 @@ public class VectorL10n: NSObject { public static var spaceTag: String { return VectorL10n.tr("Vector", "space_tag") } + /// Add room + public static var spacesAddRoom: String { + return VectorL10n.tr("Vector", "spaces_add_room") + } /// Adding rooms coming soon public static var spacesAddRoomsComingSoonTitle: String { return VectorL10n.tr("Vector", "spaces_add_rooms_coming_soon_title") @@ -5015,6 +5023,10 @@ public class VectorL10n: NSObject { public static var spacesHomeSpaceTitle: String { return VectorL10n.tr("Vector", "spaces_home_space_title") } + /// Invite people + public static var spacesInvitePeople: String { + return VectorL10n.tr("Vector", "spaces_invite_people") + } /// Invites coming soon public static var spacesInvitesComingSoonTitle: String { return VectorL10n.tr("Vector", "spaces_invites_coming_soon_title") diff --git a/Riot/Modules/People/PeopleViewController.m b/Riot/Modules/People/PeopleViewController.m index 93157578f..d74cddc98 100644 --- a/Riot/Modules/People/PeopleViewController.m +++ b/Riot/Modules/People/PeopleViewController.m @@ -66,8 +66,9 @@ // This will be used by the shared RecentsDataSource instance for sanity checks (see UITableViewDataSource methods). self.recentsTableView.tag = RecentsDataSourceModePeople; + UIImage *fabImage = self.dataSource.currentSpace == nil ? [UIImage imageNamed:@"people_floating_action"] : [UIImage imageNamed:@"add_member_floating_action"]; // Add the (+) button programmatically - plusButtonImageView = [self vc_addFABWithImage:[UIImage imageNamed:@"people_floating_action"] + plusButtonImageView = [self vc_addFABWithImage:fabImage target:self action:@selector(onPlusButtonPressed)]; } diff --git a/Riot/Modules/Room/Members/RoomParticipantsViewController.h b/Riot/Modules/Room/Members/RoomParticipantsViewController.h index 9f04901b7..0afbc219e 100644 --- a/Riot/Modules/Room/Members/RoomParticipantsViewController.h +++ b/Riot/Modules/Room/Members/RoomParticipantsViewController.h @@ -42,7 +42,7 @@ 'RoomParticipantsViewController' instance is used to edit members of the room defined by the property 'mxRoom'. When this property is nil, the view controller is empty. */ -@interface RoomParticipantsViewController : MXKViewController +@interface RoomParticipantsViewController : MXKViewController { @protected /** @@ -91,6 +91,7 @@ @property (nonatomic) BOOL showCancelBarButtonItem; @property (nonatomic) BOOL showParticipantCustomAccessoryView; +@property (nonatomic) BOOL showInviteUserFab; /** The delegate for the view controller. diff --git a/Riot/Modules/Room/Members/RoomParticipantsViewController.m b/Riot/Modules/Room/Members/RoomParticipantsViewController.m index 5761109c7..ee8c92bdd 100644 --- a/Riot/Modules/Room/Members/RoomParticipantsViewController.m +++ b/Riot/Modules/Room/Members/RoomParticipantsViewController.m @@ -86,6 +86,7 @@ self.enableBarTintColorStatusChange = NO; self.rageShakeManager = [RageShakeManager sharedManager]; self.showParticipantCustomAccessoryView = YES; + self.showInviteUserFab = YES; } - (void)viewDidLoad @@ -141,11 +142,13 @@ [self.tableView registerClass:ContactTableViewCell.class forCellReuseIdentifier:@"ParticipantTableViewCellId"]; - - // Add invite members button programmatically - [self vc_addFABWithImage:[UIImage imageNamed:@"add_member_floating_action"] - target:self - action:@selector(onAddParticipantButtonPressed)]; + if (_showInviteUserFab) + { + // Add invite members button programmatically + [self vc_addFABWithImage:[UIImage imageNamed:@"add_member_floating_action"] + target:self + action:@selector(onAddParticipantButtonPressed)]; + } // Observe user interface theme change. kThemeServiceDidChangeThemeNotificationObserver = [[NSNotificationCenter defaultCenter] addObserverForName:kThemeServiceDidChangeThemeNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notif) { diff --git a/Riot/Modules/Room/ParticipantsInviteModal/ContactsPickerViewModel.swift b/Riot/Modules/Room/ParticipantsInviteModal/ContactsPickerViewModel.swift index e0d9b6e31..95d61e732 100644 --- a/Riot/Modules/Room/ParticipantsInviteModal/ContactsPickerViewModel.swift +++ b/Riot/Modules/Room/ParticipantsInviteModal/ContactsPickerViewModel.swift @@ -200,7 +200,8 @@ extension ContactsPickerViewModel: ContactsTableViewControllerDelegate { return } - let message = VectorL10n.roomParticipantsInvitePromptMsg(contact.displayName) + let roomName = room.displayName ?? VectorL10n.spaceTag + let message = VectorL10n.roomParticipantsInvitePromptToMsg(contact.displayName, roomName) coordinatorDelegate?.contactsPickerViewModel(self, display: message, title: VectorL10n.roomParticipantsInvitePromptTitle, actions: [ UIAlertAction(title: MatrixKitL10n.cancel, style: .cancel, handler: nil), diff --git a/Riot/Modules/Spaces/SpaceMembers/MemberList/AddItemHeaderView.swift b/Riot/Modules/Spaces/SpaceMembers/MemberList/AddItemHeaderView.swift new file mode 100644 index 000000000..c28aa5859 --- /dev/null +++ b/Riot/Modules/Spaces/SpaceMembers/MemberList/AddItemHeaderView.swift @@ -0,0 +1,88 @@ +// +// Copyright 2020 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 +import Reusable + +@objc +protocol AddItemHeaderViewDelegate: AnyObject { + func addItemHeaderView(_ headerView: AddItemHeaderView, didTapButton button: UIButton) +} + +@objcMembers +final class AddItemHeaderView: UIView, NibLoadable, Themable { + + // MARK: - Constants + + private enum Constants { + static let buttonHighlightedAlpha: CGFloat = 0.2 + } + + // MARK: - Properties + + @IBOutlet private weak var button: UIButton! + @IBOutlet private weak var iconBackgroundView: UIView! + @IBOutlet private weak var iconView: UIImageView! + @IBOutlet private weak var titleLabel: UILabel! + + weak var delegate: AddItemHeaderViewDelegate? + + private var title: String? { + didSet { + titleLabel.text = title + } + } + private var icon: UIImage? { + didSet { + iconView.image = icon + } + } + + // MARK: - Setup + + static func instantiate(title: String?, icon: UIImage?) -> AddItemHeaderView { + let view = AddItemHeaderView.loadFromNib() + view.icon = icon + view.title = title + view.update(theme: ThemeService.shared().theme) + return view + } + + // MARK: - Life cycle + + override func awakeFromNib() { + super.awakeFromNib() + + button.addTarget(self, action: #selector(buttonAction), for: .touchUpInside) + iconBackgroundView.layer.masksToBounds = true + iconBackgroundView.layer.cornerRadius = iconBackgroundView.bounds.width / 2 + } + + // MARK: - Public + + func update(theme: Theme) { + iconBackgroundView.layer.backgroundColor = theme.colors.quinaryContent.cgColor + iconView.tintColor = theme.colors.secondaryContent + titleLabel.textColor = theme.colors.primaryContent + titleLabel.font = theme.fonts.headline + } + + // MARK: - Action + + @objc private func buttonAction(_ sender: UIButton) { + delegate?.addItemHeaderView(self, didTapButton: button) + } +} diff --git a/Riot/Modules/Spaces/SpaceMembers/MemberList/AddItemHeaderView.xib b/Riot/Modules/Spaces/SpaceMembers/MemberList/AddItemHeaderView.xib new file mode 100644 index 000000000..0fb31911b --- /dev/null +++ b/Riot/Modules/Spaces/SpaceMembers/MemberList/AddItemHeaderView.xib @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/Spaces/SpaceMembers/MemberList/SpaceMemberListCoordinator.swift b/Riot/Modules/Spaces/SpaceMembers/MemberList/SpaceMemberListCoordinator.swift index 77ec12f62..76ae70b78 100644 --- a/Riot/Modules/Spaces/SpaceMembers/MemberList/SpaceMemberListCoordinator.swift +++ b/Riot/Modules/Spaces/SpaceMembers/MemberList/SpaceMemberListCoordinator.swift @@ -70,4 +70,8 @@ extension SpaceMemberListCoordinator: SpaceMemberListViewModelCoordinatorDelegat func spaceMemberListViewModelDidCancel(_ viewModel: SpaceMemberListViewModelType) { self.delegate?.spaceMemberListCoordinatorDidCancel(self) } + + func spaceMemberListViewModelShowInvite(_ viewModel: SpaceMemberListViewModelType) { + self.delegate?.spaceMemberListCoordinatorShowInvite(self) + } } diff --git a/Riot/Modules/Spaces/SpaceMembers/MemberList/SpaceMemberListCoordinatorType.swift b/Riot/Modules/Spaces/SpaceMembers/MemberList/SpaceMemberListCoordinatorType.swift index 43225f787..d933457bd 100644 --- a/Riot/Modules/Spaces/SpaceMembers/MemberList/SpaceMemberListCoordinatorType.swift +++ b/Riot/Modules/Spaces/SpaceMembers/MemberList/SpaceMemberListCoordinatorType.swift @@ -21,6 +21,7 @@ import Foundation protocol SpaceMemberListCoordinatorDelegate: AnyObject { func spaceMemberListCoordinator(_ coordinator: SpaceMemberListCoordinatorType, didSelect member: MXRoomMember, from sourceView: UIView?) func spaceMemberListCoordinatorDidCancel(_ coordinator: SpaceMemberListCoordinatorType) + func spaceMemberListCoordinatorShowInvite(_ coordinator: SpaceMemberListCoordinatorType) } /// `SpaceMemberListCoordinatorType` is a protocol describing a Coordinator that handle key backup setup passphrase navigation flow. diff --git a/Riot/Modules/Spaces/SpaceMembers/MemberList/SpaceMemberListViewAction.swift b/Riot/Modules/Spaces/SpaceMembers/MemberList/SpaceMemberListViewAction.swift index 83118ce29..71b899a25 100644 --- a/Riot/Modules/Spaces/SpaceMembers/MemberList/SpaceMemberListViewAction.swift +++ b/Riot/Modules/Spaces/SpaceMembers/MemberList/SpaceMemberListViewAction.swift @@ -23,4 +23,5 @@ enum SpaceMemberListViewAction { case loadData case complete(_ selectedMember: MXRoomMember, _ sourceView: UIView?) case cancel + case invite } diff --git a/Riot/Modules/Spaces/SpaceMembers/MemberList/SpaceMemberListViewController.swift b/Riot/Modules/Spaces/SpaceMembers/MemberList/SpaceMemberListViewController.swift index d05076758..08af1db13 100644 --- a/Riot/Modules/Spaces/SpaceMembers/MemberList/SpaceMemberListViewController.swift +++ b/Riot/Modules/Spaces/SpaceMembers/MemberList/SpaceMemberListViewController.swift @@ -36,6 +36,7 @@ final class SpaceMemberListViewController: RoomParticipantsViewController { private var activityPresenter: ActivityIndicatorPresenter! private var titleView: MainTitleView! private var emptyView: SearchEmptyView! + private let inviteHeaderView = AddItemHeaderView.instantiate(title: VectorL10n.spacesInvitePeople, icon: Asset.Images.spaceInviteUser.image) private var emptyViewArtwork: UIImage { return ThemeService.shared().isCurrentThemeDark() ? Asset.Images.peopleEmptyScreenArtworkDark.image : Asset.Images.peopleEmptyScreenArtwork.image @@ -47,6 +48,7 @@ final class SpaceMemberListViewController: RoomParticipantsViewController { let viewController = SpaceMemberListViewController() viewController.viewModel = viewModel viewController.showParticipantCustomAccessoryView = false + viewController.showInviteUserFab = false viewController.theme = ThemeService.shared().theme viewController.emptyView = SearchEmptyView() return viewController @@ -71,14 +73,21 @@ final class SpaceMemberListViewController: RoomParticipantsViewController { self.viewModel.process(viewAction: .loadData) self.title = "" + + self.setupTableViewHeader() } - + override var preferredStatusBarStyle: UIStatusBarStyle { return self.theme.statusBarStyle } // MARK: - Private - + + private func setupTableViewHeader() { + inviteHeaderView.delegate = self + tableView.tableHeaderView = inviteHeaderView + } + private func update(theme: Theme) { self.theme = theme @@ -91,6 +100,8 @@ final class SpaceMemberListViewController: RoomParticipantsViewController { theme.applyStyle(onSearchBar: self.searchBarView) self.titleView.update(theme: theme) self.emptyView.update(theme: theme) + + self.inviteHeaderView.update(theme: theme) } private func registerThemeServiceDidChangeThemeNotification() { @@ -154,7 +165,7 @@ final class SpaceMemberListViewController: RoomParticipantsViewController { // MARK: - Actions @objc private func onAddParticipantButtonPressed() { - self.errorPresenter.presentError(from: self, title: VectorL10n.spacesInvitesComingSoonTitle, message: VectorL10n.spacesComingSoonDetail, animated: true, handler: nil) + self.viewModel.process(viewAction: .invite) } private func cancelButtonAction() { @@ -200,3 +211,10 @@ extension SpaceMemberListViewController: SpaceMemberListViewModelViewDelegate { self.render(viewState: viewSate) } } + +// MARK: - SpaceMemberListViewModelViewDelegate +extension SpaceMemberListViewController: AddItemHeaderViewDelegate { + func addItemHeaderView(_ headerView: AddItemHeaderView, didTapButton button: UIButton) { + self.viewModel.process(viewAction: .invite) + } +} diff --git a/Riot/Modules/Spaces/SpaceMembers/MemberList/SpaceMemberListViewModel.swift b/Riot/Modules/Spaces/SpaceMembers/MemberList/SpaceMemberListViewModel.swift index c8d7ec1fd..91b555928 100644 --- a/Riot/Modules/Spaces/SpaceMembers/MemberList/SpaceMemberListViewModel.swift +++ b/Riot/Modules/Spaces/SpaceMembers/MemberList/SpaceMemberListViewModel.swift @@ -57,6 +57,8 @@ final class SpaceMemberListViewModel: SpaceMemberListViewModelType { case .cancel: self.cancelOperations() self.coordinatorDelegate?.spaceMemberListViewModelDidCancel(self) + case .invite: + self.coordinatorDelegate?.spaceMemberListViewModelShowInvite(self) } } diff --git a/Riot/Modules/Spaces/SpaceMembers/MemberList/SpaceMemberListViewModelType.swift b/Riot/Modules/Spaces/SpaceMembers/MemberList/SpaceMemberListViewModelType.swift index a07ceb55e..4b4a0e21c 100644 --- a/Riot/Modules/Spaces/SpaceMembers/MemberList/SpaceMemberListViewModelType.swift +++ b/Riot/Modules/Spaces/SpaceMembers/MemberList/SpaceMemberListViewModelType.swift @@ -25,6 +25,7 @@ protocol SpaceMemberListViewModelViewDelegate: AnyObject { protocol SpaceMemberListViewModelCoordinatorDelegate: AnyObject { func spaceMemberListViewModel(_ viewModel: SpaceMemberListViewModelType, didSelect member: MXRoomMember, from sourceView: UIView?) func spaceMemberListViewModelDidCancel(_ viewModel: SpaceMemberListViewModelType) + func spaceMemberListViewModelShowInvite(_ viewModel: SpaceMemberListViewModelType) } /// Protocol describing the view model used by `SpaceMemberListViewController` diff --git a/Riot/Modules/Spaces/SpaceMembers/SpaceMembersCoordinator.swift b/Riot/Modules/Spaces/SpaceMembers/SpaceMembersCoordinator.swift index 9a0c44ac9..d74109024 100644 --- a/Riot/Modules/Spaces/SpaceMembers/SpaceMembersCoordinator.swift +++ b/Riot/Modules/Spaces/SpaceMembers/SpaceMembersCoordinator.swift @@ -131,8 +131,34 @@ extension SpaceMembersCoordinator: SpaceMemberListCoordinatorDelegate { func spaceMemberListCoordinatorDidCancel(_ coordinator: SpaceMemberListCoordinatorType) { self.delegate?.spaceMembersCoordinatorDidCancel(self) } + + func spaceMemberListCoordinatorShowInvite(_ coordinator: SpaceMemberListCoordinatorType) { + guard let space = parameters.session.spaceService.getSpace(withId: parameters.spaceId), let spaceRoom = space.room else { + MXLog.error("[SpaceMembersCoordinator] spaceMemberListCoordinatorShowInvite: failed to find space with id \(parameters.spaceId)") + return + } + + let coordinator = ContactsPickerCoordinator(session: parameters.session, room: spaceRoom, currentSearchText: nil, actualParticipants: nil, invitedParticipants: nil, userParticipant: nil, navigationRouter: navigationRouter) + coordinator.delegate = self + coordinator.start() + childCoordinators.append(coordinator) + } } +// MARK: - ContactsPickerCoordinatorDelegate +extension SpaceMembersCoordinator: ContactsPickerCoordinatorDelegate { + func contactsPickerCoordinatorDidStartLoading(_ coordinator: ContactsPickerCoordinatorType) { + } + + func contactsPickerCoordinatorDidEndLoading(_ coordinator: ContactsPickerCoordinatorType) { + } + + func contactsPickerCoordinatorDidClose(_ coordinator: ContactsPickerCoordinatorType) { + childCoordinators.removeLast() + } +} + +// MARK: - SpaceMemberDetailCoordinatorDelegate extension SpaceMembersCoordinator: SpaceMemberDetailCoordinatorDelegate { func spaceMemberDetailCoordinator(_ coordinator: SpaceMemberDetailCoordinatorType, showRoomWithId roomId: String) { if !UIDevice.current.isPhone, let memberDetailCoordinator = self.memberDetailCoordinator { diff --git a/Riot/Modules/Spaces/SpaceRoomList/ExploreRoom/SpaceChildSpaceViewCell.xib b/Riot/Modules/Spaces/SpaceRoomList/ExploreRoom/SpaceChildSpaceViewCell.xib index 2559fbcda..57f11e812 100644 --- a/Riot/Modules/Spaces/SpaceRoomList/ExploreRoom/SpaceChildSpaceViewCell.xib +++ b/Riot/Modules/Spaces/SpaceRoomList/ExploreRoom/SpaceChildSpaceViewCell.xib @@ -26,24 +26,24 @@ - + - + - + - + - + - + -