From 99fa4392a72aa5b9140d2962e4f55e004c471edc Mon Sep 17 00:00:00 2001 From: Gil Eluard Date: Tue, 23 Nov 2021 09:35:32 +0100 Subject: [PATCH 01/15] [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 ee142535befe1088cff8d61cab56562b1dd8211c Mon Sep 17 00:00:00 2001 From: Gil Eluard Date: Tue, 23 Nov 2021 09:39:17 +0100 Subject: [PATCH 02/15] [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 3ce1f1c630099a0ff976cee36644aa22223e92cb Mon Sep 17 00:00:00 2001 From: Gil Eluard Date: Wed, 1 Dec 2021 23:56:59 +0100 Subject: [PATCH 03/15] [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 f7b59f1a61daf56704f68d238dad8299e64f6502 Mon Sep 17 00:00:00 2001 From: Gil Eluard Date: Thu, 9 Dec 2021 09:04:21 +0100 Subject: [PATCH 04/15] [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 697624e3c43a0c5b01d6a68af1a36cf2c3e91674 Mon Sep 17 00:00:00 2001 From: Gil Eluard Date: Mon, 13 Dec 2021 14:43:05 +0100 Subject: [PATCH 05/15] [iOS] Create public space #143 - Update after design review --- .../Common/Util/ResponderManager.swift | 6 +- .../Common/Util/RoundedBorderTextEditor.swift | 18 +++ .../Common/Util/RoundedBorderTextField.swift | 14 ++ .../Common/Util/ThemableTextEditor.swift | 29 +++- .../Common/Util/ThemableTextField.swift | 34 ++++- .../SpaceCreationCoordinator.swift | 2 + .../View/SpaceCreationRooms.swift | 7 +- .../View/SpaceCreationSettings.swift | 125 +++++++++--------- 8 files changed, 151 insertions(+), 84 deletions(-) diff --git a/RiotSwiftUI/Modules/Common/Util/ResponderManager.swift b/RiotSwiftUI/Modules/Common/Util/ResponderManager.swift index 4dc23874c..87ce626a9 100644 --- a/RiotSwiftUI/Modules/Common/Util/ResponderManager.swift +++ b/RiotSwiftUI/Modules/Common/Util/ResponderManager.swift @@ -41,8 +41,10 @@ class ResponderManager { } static func register(view: UIView) { - view.tag = nextIndex - registeredResponders.setObject(view, forKey: NSNumber(value: view.tag)) + if registeredResponders.object(forKey: NSNumber(value: view.tag)) == nil { + view.tag = nextIndex + registeredResponders.setObject(view, forKey: NSNumber(value: view.tag)) + } } static func unregister(view: UIView) { diff --git a/RiotSwiftUI/Modules/Common/Util/RoundedBorderTextEditor.swift b/RiotSwiftUI/Modules/Common/Util/RoundedBorderTextEditor.swift index 199cfd04f..3ec7141c8 100644 --- a/RiotSwiftUI/Modules/Common/Util/RoundedBorderTextEditor.swift +++ b/RiotSwiftUI/Modules/Common/Util/RoundedBorderTextEditor.swift @@ -36,6 +36,24 @@ struct RoundedBorderTextEditor: View { @Environment(\.theme) private var theme: ThemeSwiftUI + // MARK: Setup + + init(title: String? = nil, + placeHolder: String, + text: Binding, + textMaxHeight: CGFloat? = nil, + error: Binding = .constant(nil), + onTextChanged: ((String) -> Void)? = nil, + onEditingChanged: ((Bool) -> Void)? = nil) { + self.title = title + self.placeHolder = placeHolder + self._text = text + self.textMaxHeight = textMaxHeight + self._error = error + self.onTextChanged = onTextChanged + self.onEditingChanged = onEditingChanged + } + // MARK: Public var body: some View { diff --git a/RiotSwiftUI/Modules/Common/Util/RoundedBorderTextField.swift b/RiotSwiftUI/Modules/Common/Util/RoundedBorderTextField.swift index 6a571ff86..eaf9c8ba0 100644 --- a/RiotSwiftUI/Modules/Common/Util/RoundedBorderTextField.swift +++ b/RiotSwiftUI/Modules/Common/Util/RoundedBorderTextField.swift @@ -40,6 +40,20 @@ struct RoundedBorderTextField: View { @Environment(\.theme) private var theme: ThemeSwiftUI + // MARK: Setup + + init(title: String? = nil, placeHolder: String, text: Binding, footerText: Binding = .constant(nil), isError: Binding = .constant(false), isFirstResponder: Bool = false, configuration: UIKitTextInputConfiguration = UIKitTextInputConfiguration(), onTextChanged: ((String) -> Void)? = nil, onEditingChanged: ((Bool) -> Void)? = nil) { + self.title = title + self.placeHolder = placeHolder + self._text = text + self._footerText = footerText + self._isError = isError + self.isFirstResponder = isFirstResponder + self.configuration = configuration + self.onTextChanged = onTextChanged + self.onEditingChanged = onEditingChanged + } + // MARK: Public var body: some View { diff --git a/RiotSwiftUI/Modules/Common/Util/ThemableTextEditor.swift b/RiotSwiftUI/Modules/Common/Util/ThemableTextEditor.swift index 5a406b921..e57b08d68 100644 --- a/RiotSwiftUI/Modules/Common/Util/ThemableTextEditor.swift +++ b/RiotSwiftUI/Modules/Common/Util/ThemableTextEditor.swift @@ -20,22 +20,37 @@ import SwiftUI @available(iOS 14.0, *) struct ThemableTextEditor: UIViewRepresentable { - @Environment(\.theme) private var theme: ThemeSwiftUI - + // MARK: Properties + @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() + // MARK: Private + + @Environment(\.theme) private var theme: ThemeSwiftUI + private let textView: UITextView = UITextView() + private let internalParams = InternalParams() + + // MARK: Setup + + init(text: Binding, + configuration: UIKitTextInputConfiguration = UIKitTextInputConfiguration(), + onEditingChanged: ((_ edit: Bool) -> Void)? = nil) { + self._text = text + self._configuration = State(initialValue: configuration) + self.onEditingChanged = onEditingChanged + + ResponderManager.register(view: textView) + } + + // MARK: UIViewRepresentable + func makeUIView(context: Context) -> UITextView { textView.delegate = context.coordinator textView.text = text - ResponderManager.register(view: textView) - if internalParams.isFirstResponder { textView.becomeFirstResponder() } diff --git a/RiotSwiftUI/Modules/Common/Util/ThemableTextField.swift b/RiotSwiftUI/Modules/Common/Util/ThemableTextField.swift index 50fcdd168..ef469a43e 100644 --- a/RiotSwiftUI/Modules/Common/Util/ThemableTextField.swift +++ b/RiotSwiftUI/Modules/Common/Util/ThemableTextField.swift @@ -27,17 +27,39 @@ struct UIKitTextInputConfiguration { @available(iOS 14.0, *) struct ThemableTextField: UIViewRepresentable { - @Environment(\.theme) private var theme: ThemeSwiftUI - + // MARK: Properties + @State var placeholder: String? @Binding var text: String @State var configuration: UIKitTextInputConfiguration = UIKitTextInputConfiguration() var onEditingChanged: ((_ edit: Bool) -> Void)? var onCommit: (() -> Void)? + // MARK: Private + + @Environment(\.theme) private var theme: ThemeSwiftUI + private let textField: UITextField = UITextField() private let internalParams = InternalParams() + // MARK: Setup + + init(placeholder: String? = nil, + text: Binding, + configuration: UIKitTextInputConfiguration = UIKitTextInputConfiguration(), + onEditingChanged: ((_ edit: Bool) -> Void)? = nil, + onCommit: (() -> Void)? = nil) { + self._text = text + self._placeholder = State(initialValue: placeholder) + self._configuration = State(initialValue: configuration) + self.onEditingChanged = onEditingChanged + self.onCommit = onCommit + + ResponderManager.register(view: textField) + } + + // MARK: UIViewRepresentable + func makeUIView(context: Context) -> UITextField { textField.delegate = context.coordinator textField.setContentHuggingPriority(.defaultHigh, for: .vertical) @@ -46,8 +68,6 @@ struct ThemableTextField: UIViewRepresentable { textField.text = text textField.addTarget(context.coordinator, action: #selector(Coordinator.textFieldEditingChanged(sender:)), for: .editingChanged) - - ResponderManager.register(view: textField) if internalParams.isFirstResponder { textField.becomeFirstResponder() @@ -107,7 +127,7 @@ struct ThemableTextField: UIViewRepresentable { } func textFieldShouldReturn(_ textField: UITextField) -> Bool { - if !ResponderManager.makeActiveNextResponder(of: textField) { + if parent.configuration.returnKeyType != .next || !ResponderManager.makeActiveNextResponder(of: textField) { textField.resignFirstResponder() } @@ -132,9 +152,9 @@ struct ThemableTextField: UIViewRepresentable { @available(iOS 14.0, *) extension ThemableTextField { func makeFirstResponder() -> ThemableTextField { - makeFirstResponder(true) - return self + return makeFirstResponder(true) } + func makeFirstResponder(_ isFirstResponder: Bool) -> ThemableTextField { internalParams.isFirstResponder = isFirstResponder return self diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/Coordinator/SpaceCreationCoordinator.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/Coordinator/SpaceCreationCoordinator.swift index f8484c059..c5fa17cca 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/Coordinator/SpaceCreationCoordinator.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/Coordinator/SpaceCreationCoordinator.swift @@ -256,6 +256,8 @@ final class SpaceCreationCoordinator: Coordinator { private func cancel() { if parameters.creationParameters.isModified { + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + let alert = UIAlertController(title: VectorL10n.spacesCreationCancelTitle, message: VectorL10n.spacesCreationCancelMessage, preferredStyle: .alert) alert.addAction(UIAlertAction(title: VectorL10n.stop, style: .destructive, handler: { action in self.callback?(.cancel) diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/View/SpaceCreationRooms.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/View/SpaceCreationRooms.swift index 0a7a1f670..bb31ce770 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/View/SpaceCreationRooms.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/View/SpaceCreationRooms.swift @@ -63,13 +63,8 @@ struct SpaceCreationRooms: View { .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) - } - }) + RoundedBorderTextField(title: VectorL10n.spacesCreationNewRoomsRoomNameTitle, placeHolder: viewModel.rooms[index].defaultName, text: $viewModel.rooms[index].name, configuration: UIKitTextInputConfiguration( returnKeyType: index < viewModel.rooms.endIndex - 1 ? .next : .done)) .accessibility(identifier: "roomTextField") - .id("roomTextField\(index)") } } .padding(.horizontal, 2) diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/View/SpaceCreationSettings.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/View/SpaceCreationSettings.swift index bd37b32dd..0d4a44ac9 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/View/SpaceCreationSettings.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/View/SpaceCreationSettings.swift @@ -50,18 +50,35 @@ struct SpaceCreationSettings: View { @ViewBuilder private var mainView: some View { - VStack(alignment: .center) { - formView - footerView + ZStack(alignment: .center) { + GeometryReader { geometryReader in + ScrollView(.vertical, showsIndicators: false) { + VStack(alignment: .center) { + VStack(alignment: .center) { + headerView + Spacer() + avatarView + Spacer() + } + .background(theme.colors.background) + Spacer().frame(height:370) + } + .frame(minWidth: geometryReader.size.width, minHeight: geometryReader.size.height - 2) + } + } + VStack(alignment: .center) { + Spacer() + formView + footerView + } } - .padding(EdgeInsets(top: 24, leading: 16, bottom: 24, trailing: 16)) + .padding(EdgeInsets(top: 0, leading: 16, bottom: 24, trailing: 16)) } @ViewBuilder private var headerView: some View { VStack(alignment: .center, spacing: nil) { Text(VectorL10n.spacesCreationSettingsMessage).multilineTextAlignment(.center) - Spacer().frame(height: 22) } } @@ -70,7 +87,7 @@ struct SpaceCreationSettings: View { ZStack(alignment: .bottomTrailing) { GeometryReader { reader in ZStack { - SpaceAvatarImage(mxContentUri: viewModel.viewState.avatar.mxContentUri, matrixItemId: viewModel.viewState.avatar.matrixItemId, displayName: viewModel.viewState.avatar.displayName, size: .xxLarge) + SpaceAvatarImage(mxContentUri: viewModel.viewState.avatar.mxContentUri, matrixItemId: viewModel.viewState.avatar.matrixItemId, displayName: viewModel.viewState.avatar.displayName, size: .xxLarge) .padding(6) if let image = viewModel.viewState.avatarImage { Image(uiImage: image) @@ -95,55 +112,48 @@ struct SpaceCreationSettings: View { @ViewBuilder private var formView: some View { - GeometryReader { geometryReader in - ScrollView { - ScrollViewReader { scrollViewReader in - VStack { - headerView - Spacer() - 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) - } - }) - .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: .addressChanged(newText)) - }, onEditingChanged: { editing in -// if editing { -// scrollDown(reader: scrollViewReader) -// } - }) - .id("addressTextField") - .accessibility(identifier: "addressTextField") - .padding(.horizontal, 2) - .padding(.bottom, 3) - } - } - .animation(.easeOut(duration: 0.2)) - .frame(minHeight: geometryReader.size.height - 2) - } + VStack{ + 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)) + }) + .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)) + }) + .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: .addressChanged(newText)) + }) + .padding(.horizontal, 2) + .padding(.bottom, 3) + .accessibility(identifier: "addressTextField") } } + .background(theme.colors.background) } @ViewBuilder @@ -153,15 +163,6 @@ 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 From fdf6395f48c4e3267767faea81d302ea87154c2a Mon Sep 17 00:00:00 2001 From: Gil Eluard Date: Wed, 15 Dec 2021 20:09:37 +0100 Subject: [PATCH 06/15] [iOS] Create public space #143 - Updated swiftui tests --- RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift | 8 +++++++- .../Test/UI/SpaceCreationPostProcessUITests.swift | 4 ---- .../Unit/SpaceCreationPostProcessViewModelTests.swift | 8 ++------ .../Test/Unit/SpaceCreationSettingsViewModelTests.swift | 4 ++-- 4 files changed, 11 insertions(+), 13 deletions(-) diff --git a/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift b/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift index bceefcd26..a5e70056a 100644 --- a/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift +++ b/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift @@ -25,7 +25,13 @@ enum MockAppScreens { MockTemplateRoomChatScreenState.self, MockUserSuggestionScreenState.self, MockPollEditFormScreenState.self, - MockPollTimelineScreenState.self + MockPollTimelineScreenState.self, + MockSpaceCreationEmailInvitesScreenState.self, + MockSpaceCreationMatrixItemChooserScreenState.self, + MockSpaceCreationMenuScreenState.self, + MockSpaceCreationRoomsScreenState.self, + MockSpaceCreationSettingsScreenState.self, + MockSpaceCreationPostProcessScreenState.self, ] } diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Test/UI/SpaceCreationPostProcessUITests.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Test/UI/SpaceCreationPostProcessUITests.swift index 6f716efab..2f42eacf2 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Test/UI/SpaceCreationPostProcessUITests.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Test/UI/SpaceCreationPostProcessUITests.swift @@ -32,10 +32,6 @@ class SpaceCreationPostProcessUITests: MockScreenTest { func verifySpaceCreationPostProcessScreen() throws { guard let screenState = screenState as? MockSpaceCreationPostProcessScreenState else { fatalError("no screen") } - switch screenState { - case .tasks: - verifyTasksList() - } } 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 16e9dc6f7..76c1a53f4 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Test/Unit/SpaceCreationPostProcessViewModelTests.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Test/Unit/SpaceCreationPostProcessViewModelTests.swift @@ -41,19 +41,15 @@ class SpaceCreationPostProcessViewModelTests: XCTestCase { } func testUpateToNextTask() { - let tasksPublisher = context.$viewState.map(\.tasks).removeDuplicates() - let awaitDeferred = xcAwaitDeferred(tasksPublisher) service.simulateUpdate(tasks: MockSpaceCreationPostProcessService.nextStepTasks) - XCTAssertEqual(try awaitDeferred(), MockSpaceCreationPostProcessService.nextStepTasks) + XCTAssertEqual(context.viewState.tasks, MockSpaceCreationPostProcessService.nextStepTasks) XCTAssertEqual(context.viewState.errorCount, 2) XCTAssertEqual(context.viewState.isFinished, false) } func testLastTaskDone() { - let tasksPublisher = context.$viewState.map(\.tasks).removeDuplicates() - let awaitDeferred = xcAwaitDeferred(tasksPublisher) service.simulateUpdate(tasks: MockSpaceCreationPostProcessService.lastTaskDoneWithError) - XCTAssertEqual(try awaitDeferred(), MockSpaceCreationPostProcessService.lastTaskDoneWithError) + XCTAssertEqual(context.viewState.tasks, MockSpaceCreationPostProcessService.lastTaskDoneWithError) XCTAssertEqual(context.viewState.errorCount, 2) XCTAssertEqual(context.viewState.isFinished, true) } diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Test/Unit/SpaceCreationSettingsViewModelTests.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Test/Unit/SpaceCreationSettingsViewModelTests.swift index e95f0f87d..135ac6fd0 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Test/Unit/SpaceCreationSettingsViewModelTests.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Test/Unit/SpaceCreationSettingsViewModelTests.swift @@ -45,8 +45,8 @@ class SpaceCreationSettingsViewModelTests: XCTestCase { 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.defaultAddress, "fake-uri") + XCTAssertEqual(context.viewState.addressMessage, VectorL10n.spacesCreationAddressDefaultMessage("#fake-uri:fake-domain.org")) XCTAssertEqual(context.viewState.avatarImage, Asset.Images.appSymbol.image) XCTAssertEqual(context.roomName, creationParameters.name) XCTAssertEqual(context.topic, creationParameters.topic) From 46ff9109415077e3183451716033e1c74f2f3b0c Mon Sep 17 00:00:00 2001 From: Gil Eluard Date: Wed, 15 Dec 2021 21:04:37 +0100 Subject: [PATCH 07/15] [iOS] Create public space #143 - Consolidated current flow --- .../ViewModel/SpaceCreationEmailInvitesViewModel.swift | 1 + .../SpaceCreationMatrixItemChooserViewModel.swift | 1 + .../Model/SpaceCreationParameters.swift | 10 ++++++++++ .../MatrixSDK/SpaceCreationPostProcessService.swift | 5 +++-- 4 files changed, 15 insertions(+), 2 deletions(-) diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/ViewModel/SpaceCreationEmailInvitesViewModel.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/ViewModel/SpaceCreationEmailInvitesViewModel.swift index fc36546eb..a6ab659bc 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/ViewModel/SpaceCreationEmailInvitesViewModel.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/ViewModel/SpaceCreationEmailInvitesViewModel.swift @@ -89,6 +89,7 @@ class SpaceCreationEmailInvitesViewModel: SpaceCreationEmailInvitesViewModelType private func done() { self.creationParameters.emailInvites = self.context.emailInvites + self.creationParameters.inviteType = .email let emailAddressesValidity = service.validate(self.context.emailInvites) dispatch(action: .updateEmailValidity(emailAddressesValidity)) diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/ViewModel/SpaceCreationMatrixItemChooserViewModel.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/ViewModel/SpaceCreationMatrixItemChooserViewModel.swift index 55b4f762d..549bd8f6e 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/ViewModel/SpaceCreationMatrixItemChooserViewModel.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/ViewModel/SpaceCreationMatrixItemChooserViewModel.swift @@ -84,6 +84,7 @@ class SpaceCreationMatrixItemChooserViewModel: SpaceCreationMatrixItemChooserVie let selectedItemIds = Array(spaceCreationMatrixItemChooserService.selectedItemIdsSubject.value) switch spaceCreationMatrixItemChooserService.type { case .people: + creationParams.inviteType = .userId creationParams.userIdInvites = selectedItemIds default: creationParams.addedRoomIds = selectedItemIds diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Model/SpaceCreationParameters.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Model/SpaceCreationParameters.swift index e14f66184..5255923b0 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Model/SpaceCreationParameters.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Model/SpaceCreationParameters.swift @@ -17,6 +17,11 @@ import Foundation import UIKit +enum SpaceCreationInviteType { + case email + case userId +} + class SpaceCreationParameters { var name: String? { didSet { @@ -89,6 +94,11 @@ class SpaceCreationParameters { isModified = true } } + var inviteType: SpaceCreationInviteType = .email { + didSet { + isModified = true + } + } private(set) var isModified: Bool = false } diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Service/MatrixSDK/SpaceCreationPostProcessService.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Service/MatrixSDK/SpaceCreationPostProcessService.swift index 944ae61f5..c10113638 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Service/MatrixSDK/SpaceCreationPostProcessService.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Service/MatrixSDK/SpaceCreationPostProcessService.swift @@ -114,7 +114,7 @@ class SpaceCreationPostProcessService: SpaceCreationPostProcessServiceProtocol { })) } - if creationParams.userIdInvites.isEmpty { + if creationParams.inviteType == .email { let emailInviteCount = creationParams.userDefinedEmailInvites.count if emailInviteCount > 0 { let subTasks = creationParams.userDefinedEmailInvites.map { emailAddress in @@ -170,7 +170,8 @@ class SpaceCreationPostProcessService: SpaceCreationPostProcessServiceProtocol { 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 + let userIdInvites = creationParams.inviteType == .userId ? creationParams.userIdInvites : [] + session.spaceService.createSpace(withName: creationParams.name, topic: creationParams.topic, isPublic: creationParams.isPublic, aliasLocalPart: alias, inviteArray: userIdInvites) { [weak self] response in guard let self = self else { return } if response.isFailure { self.updateCurrentTask(with: .failure) From 7194e103502a20239cc1dbc1f375435bbe1989a6 Mon Sep 17 00:00:00 2001 From: Gil Eluard Date: Thu, 16 Dec 2021 09:54:54 +0100 Subject: [PATCH 08/15] [iOS] Create public space #143 - UI tweaks in space settings screen --- .../View/SpaceCreationSettings.swift | 52 ++++++++++++------- 1 file changed, 34 insertions(+), 18 deletions(-) diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/View/SpaceCreationSettings.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/View/SpaceCreationSettings.swift index 0d4a44ac9..c58bff7b2 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/View/SpaceCreationSettings.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/View/SpaceCreationSettings.swift @@ -50,26 +50,41 @@ struct SpaceCreationSettings: View { @ViewBuilder private var mainView: some View { - ZStack(alignment: .center) { - GeometryReader { geometryReader in - ScrollView(.vertical, showsIndicators: false) { - VStack(alignment: .center) { - VStack(alignment: .center) { - headerView - Spacer() - avatarView - Spacer() - } - .background(theme.colors.background) - Spacer().frame(height:370) + GeometryReader { geometryReader in + ZStack(alignment: .center) { + VStack(alignment: .center) { + if geometryReader.size.height > 650 { + Spacer().frame(height: 10) } - .frame(minWidth: geometryReader.size.width, minHeight: geometryReader.size.height - 2) + headerView + if !viewModel.viewState.showRoomAddress { + Spacer().frame(height: geometryReader.size.height / 20) + } else { + Spacer().frame(height: geometryReader.size.height / 30) + } + avatarView + Spacer() + } + .background(theme.colors.background) + VStack(alignment: .center) { + if viewModel.viewState.showRoomAddress && geometryReader.size.height > 650 { + Spacer() + } + Spacer() + formView + if !viewModel.viewState.showRoomAddress { + if geometryReader.size.height > 650 { + Spacer().frame(height:geometryReader.size.height / 4) + } else { + Spacer().frame(height:geometryReader.size.height / 4) + } + } else { + if geometryReader.size.height > 650 { + Spacer().frame(height:geometryReader.size.height / 10) + } + } + footerView } - } - VStack(alignment: .center) { - Spacer() - formView - footerView } } .padding(EdgeInsets(top: 0, leading: 16, bottom: 24, trailing: 16)) @@ -98,6 +113,7 @@ struct SpaceCreationSettings: View { } }.padding(10) .gesture(TapGesture().onEnded { _ in + ResponderManager.resignFirstResponder() viewModel.send(viewAction: .pickImage(reader.frame(in: .global))) }) } From a5f9dd49c46800228b4293c5a6f9a560acc2e7f0 Mon Sep 17 00:00:00 2001 From: Gil Eluard Date: Thu, 16 Dec 2021 10:59:48 +0100 Subject: [PATCH 09/15] [iOS] Create public space #143 - UI tweaks in space settings screen --- .../SpaceCreationSettings/View/SpaceCreationSettings.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/View/SpaceCreationSettings.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/View/SpaceCreationSettings.swift index c58bff7b2..a6f60c04a 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/View/SpaceCreationSettings.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/View/SpaceCreationSettings.swift @@ -44,6 +44,9 @@ struct SpaceCreationSettings: View { } .background(theme.colors.background) .navigationBarHidden(true) + .onTapGesture { + ResponderManager.resignFirstResponder() + } } // MARK: - Private From 8f93d61ab78b22530ed3bccca7351d67eafa3e03 Mon Sep 17 00:00:00 2001 From: Gil Eluard Date: Thu, 23 Dec 2021 15:24:46 +0100 Subject: [PATCH 10/15] [iOS] Create public space #143 - UI tweaks in space settings screen --- .../Model/SpaceCreationParameters.swift | 2 +- .../SpaceCreationPostProcessService.swift | 1 + .../View/SpaceCreationPostProcess.swift | 2 +- .../View/SpaceCreationSettings.swift | 133 +++++++----------- 4 files changed, 56 insertions(+), 82 deletions(-) diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Model/SpaceCreationParameters.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Model/SpaceCreationParameters.swift index 5255923b0..a9c2036f4 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Model/SpaceCreationParameters.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Model/SpaceCreationParameters.swift @@ -99,7 +99,7 @@ class SpaceCreationParameters { isModified = true } } - private(set) var isModified: Bool = false + var isModified: Bool = false } struct SpaceCreationNewRoom: Equatable { diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Service/MatrixSDK/SpaceCreationPostProcessService.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Service/MatrixSDK/SpaceCreationPostProcessService.swift index c10113638..5e95e8f6f 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Service/MatrixSDK/SpaceCreationPostProcessService.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Service/MatrixSDK/SpaceCreationPostProcessService.swift @@ -176,6 +176,7 @@ class SpaceCreationPostProcessService: SpaceCreationPostProcessServiceProtocol { if response.isFailure { self.updateCurrentTask(with: .failure) } else { + self.creationParams.isModified = false self.createdSpace = response.value self.updateCurrentTask(with: .success) self.runNextTask() diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/View/SpaceCreationPostProcess.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/View/SpaceCreationPostProcess.swift index 37b8d47d4..47bc0bf7f 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/View/SpaceCreationPostProcess.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/View/SpaceCreationPostProcess.swift @@ -72,7 +72,7 @@ struct SpaceCreationPostProcess: View { @ViewBuilder private var buttonsPanel: some View { HStack { - ThemableButton(icon: nil, title: VectorL10n.done) { + ThemableButton(icon: nil, title: VectorL10n.cancel) { viewModel.send(viewAction: .cancel) } ThemableButton(icon: nil, title: VectorL10n.retry) { diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/View/SpaceCreationSettings.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/View/SpaceCreationSettings.swift index a6f60c04a..bca8671ed 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/View/SpaceCreationSettings.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/View/SpaceCreationSettings.swift @@ -44,51 +44,15 @@ struct SpaceCreationSettings: View { } .background(theme.colors.background) .navigationBarHidden(true) - .onTapGesture { - ResponderManager.resignFirstResponder() - } } // MARK: - Private @ViewBuilder private var mainView: some View { - GeometryReader { geometryReader in - ZStack(alignment: .center) { - VStack(alignment: .center) { - if geometryReader.size.height > 650 { - Spacer().frame(height: 10) - } - headerView - if !viewModel.viewState.showRoomAddress { - Spacer().frame(height: geometryReader.size.height / 20) - } else { - Spacer().frame(height: geometryReader.size.height / 30) - } - avatarView - Spacer() - } - .background(theme.colors.background) - VStack(alignment: .center) { - if viewModel.viewState.showRoomAddress && geometryReader.size.height > 650 { - Spacer() - } - Spacer() - formView - if !viewModel.viewState.showRoomAddress { - if geometryReader.size.height > 650 { - Spacer().frame(height:geometryReader.size.height / 4) - } else { - Spacer().frame(height:geometryReader.size.height / 4) - } - } else { - if geometryReader.size.height > 650 { - Spacer().frame(height:geometryReader.size.height / 10) - } - } - footerView - } - } + VStack(alignment: .center) { + formView + footerView } .padding(EdgeInsets(top: 0, leading: 16, bottom: 24, trailing: 16)) } @@ -97,6 +61,7 @@ struct SpaceCreationSettings: View { private var headerView: some View { VStack(alignment: .center, spacing: nil) { Text(VectorL10n.spacesCreationSettingsMessage).multilineTextAlignment(.center) + Spacer().frame(height: 22) } } @@ -105,7 +70,7 @@ struct SpaceCreationSettings: View { ZStack(alignment: .bottomTrailing) { GeometryReader { reader in ZStack { - SpaceAvatarImage(mxContentUri: viewModel.viewState.avatar.mxContentUri, matrixItemId: viewModel.viewState.avatar.matrixItemId, displayName: viewModel.viewState.avatar.displayName, size: .xxLarge) + SpaceAvatarImage(mxContentUri: viewModel.viewState.avatar.mxContentUri, matrixItemId: viewModel.viewState.avatar.matrixItemId, displayName: viewModel.viewState.avatar.displayName, size: .xxLarge) .padding(6) if let image = viewModel.viewState.avatarImage { Image(uiImage: image) @@ -131,48 +96,47 @@ struct SpaceCreationSettings: View { @ViewBuilder private var formView: some View { - VStack{ - 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)) - }) - .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)) - }) - .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: .addressChanged(newText)) - }) - .padding(.horizontal, 2) - .padding(.bottom, 3) - .accessibility(identifier: "addressTextField") + GeometryReader { geometryReader in + ScrollView { + ScrollViewReader { scrollViewReader in + VStack { + headerView + Spacer() + avatarView + Spacer().frame(height:40) + RoundedBorderTextField(title: VectorL10n.createRoomPlaceholderName, placeHolder: "", text: $viewModel.roomName, footerText: .constant(viewModel.viewState.roomNameError), isError: .constant(true), isFirstResponder: false, configuration: UIKitTextInputConfiguration( returnKeyType: .next), onTextChanged: { newText in + viewModel.send(viewAction: .nameChanged(newText)) + }) + .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) + } + }) + .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: .addressChanged(newText)) + }) + .id("addressTextField") + .accessibility(identifier: "addressTextField") + .padding(.horizontal, 2) + .padding(.bottom, 3) + } + Spacer() + } + .animation(.easeOut(duration: 0.2)) + } } } - .background(theme.colors.background) } @ViewBuilder @@ -182,6 +146,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 From 4a4ef57051e93e3e679a1b87e552955703f7b6a7 Mon Sep 17 00:00:00 2001 From: Gil Eluard Date: Thu, 23 Dec 2021 16:26:27 +0100 Subject: [PATCH 11/15] [iOS] Create public space #143 - added changelog + additional tweaks --- .../Test/UI/SpaceCreationMatrixItemChooserUITests.swift | 3 --- .../SpaceCreationMenu/Test/UI/SpaceCreationMenuUITests.swift | 2 +- changelog.d/5224.change | 1 + 3 files changed, 2 insertions(+), 4 deletions(-) create mode 100644 changelog.d/5224.change diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Test/UI/SpaceCreationMatrixItemChooserUITests.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Test/UI/SpaceCreationMatrixItemChooserUITests.swift index 3af4d0219..e6368d86f 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Test/UI/SpaceCreationMatrixItemChooserUITests.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Test/UI/SpaceCreationMatrixItemChooserUITests.swift @@ -45,7 +45,6 @@ class SpaceCreationMatrixItemChooserUITests: MockScreenTest { 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) @@ -54,7 +53,6 @@ class SpaceCreationMatrixItemChooserUITests: MockScreenTest { 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) } @@ -62,7 +60,6 @@ class SpaceCreationMatrixItemChooserUITests: MockScreenTest { 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/SpaceCreationMenu/Test/UI/SpaceCreationMenuUITests.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Test/UI/SpaceCreationMenuUITests.swift index 068d36c55..77e6826d5 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Test/UI/SpaceCreationMenuUITests.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Test/UI/SpaceCreationMenuUITests.swift @@ -48,7 +48,7 @@ class SpaceCreationMenuUITests: MockScreenTest { let detailText = app.staticTexts["detailText"] XCTAssert(detailText.exists) - XCTAssert(detailText.label == "Some title") + XCTAssertEqual(detailText.label, "Some detail text") } } diff --git a/changelog.d/5224.change b/changelog.d/5224.change new file mode 100644 index 000000000..4a5b7afec --- /dev/null +++ b/changelog.d/5224.change @@ -0,0 +1 @@ +Space creation: Added entire space creation flow. \ No newline at end of file From 439c53122a0cb38f8ed723aaddaae0d27fae6f6d Mon Sep 17 00:00:00 2001 From: Gil Eluard Date: Mon, 10 Jan 2022 12:28:33 +0100 Subject: [PATCH 12/15] Update RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Service/MatrixSDK/SpaceCreationMatrixItemChooserService.swift Co-authored-by: manuroe --- .../MatrixSDK/SpaceCreationMatrixItemChooserService.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Service/MatrixSDK/SpaceCreationMatrixItemChooserService.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Service/MatrixSDK/SpaceCreationMatrixItemChooserService.swift index a9eb791fd..2dc9c2e7d 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Service/MatrixSDK/SpaceCreationMatrixItemChooserService.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Service/MatrixSDK/SpaceCreationMatrixItemChooserService.swift @@ -24,7 +24,7 @@ class SpaceCreationMatrixItemChooserService: SpaceCreationMatrixItemChooserServi // MARK: Private - private let processingQueue = DispatchQueue(label: "org.matrix.element.SpaceCreationMatrixItemChooserService.processingQueue") + private let processingQueue = DispatchQueue(label: "io.element.SpaceCreationMatrixItemChooserService.processingQueue") private let completionQueue = DispatchQueue.main private let session: MXSession From df7c67bd3cf269eafa19d2786d842edee9014568 Mon Sep 17 00:00:00 2001 From: Gil Eluard Date: Mon, 10 Jan 2022 12:28:50 +0100 Subject: [PATCH 13/15] Update RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Service/MatrixSDK/SpaceCreationPostProcessService.swift Co-authored-by: manuroe --- .../Service/MatrixSDK/SpaceCreationPostProcessService.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Service/MatrixSDK/SpaceCreationPostProcessService.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Service/MatrixSDK/SpaceCreationPostProcessService.swift index 5e95e8f6f..746b8dea5 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Service/MatrixSDK/SpaceCreationPostProcessService.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Service/MatrixSDK/SpaceCreationPostProcessService.swift @@ -43,7 +43,7 @@ class SpaceCreationPostProcessService: SpaceCreationPostProcessServiceProtocol { private var currentSubTaskIndex = 0 - private var processingQueue = DispatchQueue(label: "org.matrix.sdk.MXSpace.processingQueue", attributes: .concurrent) + private var processingQueue = DispatchQueue(label: "io.element.MXSpace.processingQueue", attributes: .concurrent) private lazy var stateEventBuilder: MXRoomInitialStateEventBuilder = { return MXRoomInitialStateEventBuilder() From 1bd21abdf46aa9de0c90f75623d9aeffb4536ee2 Mon Sep 17 00:00:00 2001 From: Gil Eluard Date: Mon, 24 Jan 2022 09:27:23 +0100 Subject: [PATCH 14/15] [iOS] Create public space #143 - Update after code review --- .../Common/Util/ClearViewModifier.swift | 1 + .../Common/Util/NextViewModifier.swift | 55 ------------------- .../Common/Util/ResponderManager.swift | 18 ++++++ .../Modules/Common/Util/WaitOverlay.swift | 8 +-- ...paceCreationMatrixItemChooserService.swift | 2 +- .../Model/SpaceCreationMenuRoom.swift | 5 ++ .../SpaceCreationPostProcessService.swift | 4 -- 7 files changed, 27 insertions(+), 66 deletions(-) delete mode 100644 RiotSwiftUI/Modules/Common/Util/NextViewModifier.swift diff --git a/RiotSwiftUI/Modules/Common/Util/ClearViewModifier.swift b/RiotSwiftUI/Modules/Common/Util/ClearViewModifier.swift index edf478856..116c5d610 100644 --- a/RiotSwiftUI/Modules/Common/Util/ClearViewModifier.swift +++ b/RiotSwiftUI/Modules/Common/Util/ClearViewModifier.swift @@ -16,6 +16,7 @@ import SwiftUI +/// `ClearViewModifier` aims to add a clear button (e.g. `x` button) on the right side of any text editing view @available(iOS 14.0, *) struct ClearViewModifier: ViewModifier { diff --git a/RiotSwiftUI/Modules/Common/Util/NextViewModifier.swift b/RiotSwiftUI/Modules/Common/Util/NextViewModifier.swift deleted file mode 100644 index 80f4914ce..000000000 --- a/RiotSwiftUI/Modules/Common/Util/NextViewModifier.swift +++ /dev/null @@ -1,55 +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 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.right.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/ResponderManager.swift b/RiotSwiftUI/Modules/Common/Util/ResponderManager.swift index 87ce626a9..21738bae9 100644 --- a/RiotSwiftUI/Modules/Common/Util/ResponderManager.swift +++ b/RiotSwiftUI/Modules/Common/Util/ResponderManager.swift @@ -16,6 +16,7 @@ import UIKit +/// `ResponderManager` is used to chain `SwiftUI` text editing views that embed `UIKit` text editing views using `UIViewRepresentable` class ResponderManager { private static var tagIndex: Int = 1000 @@ -40,6 +41,10 @@ class ResponderManager { return nil } + /// register the `UIKit` view as a responder + /// + /// - Parameters: + /// - view: view to be registered static func register(view: UIView) { if registeredResponders.object(forKey: NSNumber(value: view.tag)) == nil { view.tag = nextIndex @@ -47,10 +52,16 @@ class ResponderManager { } } + /// Unregister the `UIKit` view from this manager. The view won't be considered as potential next responder anymore + /// + /// - Parameters: + /// - view: view to be unregistered static func unregister(view: UIView) { registeredResponders.removeObject(forKey: NSNumber(value: view.tag)) } + /// Tries to get the focused registered responder and give the focus to it's next responder + /// - Returns: `True` if the next responder has been found and is successfully focused. `False` otherwise. static func makeActiveNextResponder() -> Bool { guard let firstResponder = self.firstResponder else { return false @@ -59,6 +70,12 @@ class ResponderManager { return makeActiveNextResponder(of: firstResponder) } + /// Give the focus to the next responder f the given `UIKit` view + /// + /// - Parameters: + /// - view: base view + /// + /// - Returns: `True` if the next responder has been found and is successfully focused. `False` otherwise. static func makeActiveNextResponder(of view: UIView) -> Bool { let nextTag = view.tag + 1 guard let nextResponder = registeredResponders.object(forKey: NSNumber(value: nextTag)) else { @@ -69,6 +86,7 @@ class ResponderManager { return true } + /// Unfocus any focused registered view. static func resignFirstResponder() { firstResponder?.resignFirstResponder() } diff --git a/RiotSwiftUI/Modules/Common/Util/WaitOverlay.swift b/RiotSwiftUI/Modules/Common/Util/WaitOverlay.swift index c63cba339..b5d341e63 100644 --- a/RiotSwiftUI/Modules/Common/Util/WaitOverlay.swift +++ b/RiotSwiftUI/Modules/Common/Util/WaitOverlay.swift @@ -16,6 +16,7 @@ import SwiftUI +/// `WaitOverlay` allows to easily add an overlay that covers the entire with an `ActivityIndicator` at the center @available(iOS 14.0, *) struct WaitOverlay: ViewModifier { // MARK: - Properties @@ -36,12 +37,7 @@ struct WaitOverlay: ViewModifier { 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)) + ActivityIndicator() } .frame(width: .infinity, height: .infinity) .transition(.opacity) diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Service/MatrixSDK/SpaceCreationMatrixItemChooserService.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Service/MatrixSDK/SpaceCreationMatrixItemChooserService.swift index a9eb791fd..7010ecdaa 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Service/MatrixSDK/SpaceCreationMatrixItemChooserService.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Service/MatrixSDK/SpaceCreationMatrixItemChooserService.swift @@ -70,7 +70,7 @@ class SpaceCreationMatrixItemChooserService: SpaceCreationMatrixItemChooserServi } case .room: self.items = session.rooms.compactMap { room in - if room.summary.roomType == .space || room.isDirect { + if room.summary.roomType == .space { return nil } diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Model/SpaceCreationMenuRoom.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Model/SpaceCreationMenuRoom.swift index f0fef99a6..27c95e48c 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Model/SpaceCreationMenuRoom.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Model/SpaceCreationMenuRoom.swift @@ -19,10 +19,15 @@ import Foundation import UIKit +/// list of IDs for the items displayed in the different menu views enum SpaceCreationMenuRoomOptionId { + /// Public space option case publicSpace + /// Private space option case privateSpace + /// Private space for internal use option case ownedPrivateSpace + /// Private space shared with members option case sharedPrivateSpace } diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Service/MatrixSDK/SpaceCreationPostProcessService.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Service/MatrixSDK/SpaceCreationPostProcessService.swift index 5e95e8f6f..6b4725454 100644 --- a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Service/MatrixSDK/SpaceCreationPostProcessService.swift +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Service/MatrixSDK/SpaceCreationPostProcessService.swift @@ -141,10 +141,6 @@ class SpaceCreationPostProcessService: SpaceCreationPostProcessServiceProtocol { return } -// createdSpaceId = session.spaceService.rootSpaceSummaries.first?.roomId -// fakeTaskExecution(task: task) -// return - switch task.type { case .createSpace: createSpace(andUpdate: task) From 54a27c777084148623853f57b70735fabdb4cdc5 Mon Sep 17 00:00:00 2001 From: Gil Eluard Date: Mon, 24 Jan 2022 15:58:02 +0100 Subject: [PATCH 15/15] [iOS] Create public space #143 - Update after code review --- RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift b/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift index bcde68991..3fb7b554a 100644 --- a/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift +++ b/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift @@ -24,7 +24,6 @@ enum MockAppScreens { MockAnalyticsPromptScreenState.self, MockUserSuggestionScreenState.self, MockPollEditFormScreenState.self, - MockPollTimelineScreenState.self, MockSpaceCreationEmailInvitesScreenState.self, MockSpaceCreationMatrixItemChooserScreenState.self, MockSpaceCreationMenuScreenState.self,