From 3d9a3a2e5bb4c3be751a8a89dd9ab806f2f96958 Mon Sep 17 00:00:00 2001 From: Andy Uhnak Date: Thu, 17 Mar 2022 12:37:42 +0000 Subject: [PATCH 01/38] Fix crash when pressing tabs --- Riot/Modules/Favorites/FavouritesViewController.m | 8 ++++++-- Riot/Modules/Home/HomeViewController.m | 8 ++++++-- Riot/Modules/People/PeopleViewController.m | 8 ++++++-- Riot/Modules/Rooms/RoomsViewController.m | 8 ++++++-- changelog.d/5547.bugfix | 1 + 5 files changed, 25 insertions(+), 8 deletions(-) create mode 100644 changelog.d/5547.bugfix diff --git a/Riot/Modules/Favorites/FavouritesViewController.m b/Riot/Modules/Favorites/FavouritesViewController.m index eb9499359..49942eeed 100644 --- a/Riot/Modules/Favorites/FavouritesViewController.m +++ b/Riot/Modules/Favorites/FavouritesViewController.m @@ -130,9 +130,13 @@ [self.tableViewPaginationThrottler throttle:^{ NSInteger section = indexPath.section; + if (tableView.numberOfSections <= section) + { + return; + } + NSInteger numberOfRowsInSection = [tableView numberOfRowsInSection:section]; - if (tableView.numberOfSections > section - && indexPath.row == numberOfRowsInSection - 1) + if (indexPath.row == numberOfRowsInSection - 1) { [self->recentsDataSource paginateInSection:section]; } diff --git a/Riot/Modules/Home/HomeViewController.m b/Riot/Modules/Home/HomeViewController.m index 0f51fd248..22db94190 100644 --- a/Riot/Modules/Home/HomeViewController.m +++ b/Riot/Modules/Home/HomeViewController.m @@ -600,9 +600,13 @@ { [self.collectionViewPaginationThrottler throttle:^{ NSInteger collectionViewSection = indexPath.section; + if (collectionView.numberOfSections <= collectionViewSection) + { + return; + } + NSInteger numberOfItemsInSection = [collectionView numberOfItemsInSection:collectionViewSection]; - if (collectionView.numberOfSections > collectionViewSection - && indexPath.item == numberOfItemsInSection - 1) + if (indexPath.item == numberOfItemsInSection - 1) { NSInteger tableViewSection = collectionView.tag; [self->recentsDataSource paginateInSection:tableViewSection]; diff --git a/Riot/Modules/People/PeopleViewController.m b/Riot/Modules/People/PeopleViewController.m index fc6b5b69e..0f8152c5e 100644 --- a/Riot/Modules/People/PeopleViewController.m +++ b/Riot/Modules/People/PeopleViewController.m @@ -105,9 +105,13 @@ [self.tableViewPaginationThrottler throttle:^{ NSInteger section = indexPath.section; + if (tableView.numberOfSections <= section) + { + return; + } + NSInteger numberOfRowsInSection = [tableView numberOfRowsInSection:section]; - if (tableView.numberOfSections > section - && indexPath.row == numberOfRowsInSection - 1) + if (indexPath.row == numberOfRowsInSection - 1) { [self->recentsDataSource paginateInSection:section]; } diff --git a/Riot/Modules/Rooms/RoomsViewController.m b/Riot/Modules/Rooms/RoomsViewController.m index 6e4e066d5..f7b54cf6a 100644 --- a/Riot/Modules/Rooms/RoomsViewController.m +++ b/Riot/Modules/Rooms/RoomsViewController.m @@ -110,9 +110,13 @@ [self.tableViewPaginationThrottler throttle:^{ NSInteger section = indexPath.section; + if (tableView.numberOfSections <= section) + { + return; + } + NSInteger numberOfRowsInSection = [tableView numberOfRowsInSection:section]; - if (tableView.numberOfSections > section - && indexPath.row == numberOfRowsInSection - 1) + if (indexPath.row == numberOfRowsInSection - 1) { [self->recentsDataSource paginateInSection:section]; } diff --git a/changelog.d/5547.bugfix b/changelog.d/5547.bugfix new file mode 100644 index 000000000..570df2392 --- /dev/null +++ b/changelog.d/5547.bugfix @@ -0,0 +1 @@ +Home: Fix crash when pressing tabs From 123bbbf3dcf4a9d6fd13452dbc374753f4802ab7 Mon Sep 17 00:00:00 2001 From: Gil Eluard Date: Thu, 17 Mar 2022 15:56:33 +0100 Subject: [PATCH 02/38] Change behaviour of avatar/self in left menu to match common paradigm and take user to their own profile/settings - Done --- .../Modules/SideMenu/SideMenuViewAction.swift | 1 + .../SideMenuViewController.storyboard | 25 ++++++++++++++----- .../SideMenu/SideMenuViewController.swift | 4 +++ Riot/Modules/SideMenu/SideMenuViewModel.swift | 2 ++ changelog.d/5500.change | 1 + 5 files changed, 27 insertions(+), 6 deletions(-) create mode 100644 changelog.d/5500.change diff --git a/Riot/Modules/SideMenu/SideMenuViewAction.swift b/Riot/Modules/SideMenu/SideMenuViewAction.swift index b494e07dc..45adc68fa 100644 --- a/Riot/Modules/SideMenu/SideMenuViewAction.swift +++ b/Riot/Modules/SideMenu/SideMenuViewAction.swift @@ -22,4 +22,5 @@ import Foundation enum SideMenuViewAction { case loadData case tap(menuItem: SideMenuItem, sourceView: UIView) + case tapHeader(sourceView: UIView) } diff --git a/Riot/Modules/SideMenu/SideMenuViewController.storyboard b/Riot/Modules/SideMenu/SideMenuViewController.storyboard index 998474b8d..579a3332f 100644 --- a/Riot/Modules/SideMenu/SideMenuViewController.storyboard +++ b/Riot/Modules/SideMenu/SideMenuViewController.storyboard @@ -1,9 +1,9 @@ - + - + @@ -28,29 +28,42 @@ - + + + + + + @@ -114,7 +127,7 @@ - + diff --git a/Riot/Modules/SideMenu/SideMenuViewController.swift b/Riot/Modules/SideMenu/SideMenuViewController.swift index f92be3bbd..344fb3764 100644 --- a/Riot/Modules/SideMenu/SideMenuViewController.swift +++ b/Riot/Modules/SideMenu/SideMenuViewController.swift @@ -198,6 +198,10 @@ final class SideMenuViewController: UIViewController { // MARK: - Actions + + @IBAction func headerTapAction(sender: UIView) { + self.viewModel.process(viewAction: .tapHeader(sourceView: sender)) + } } // MARK: - SideMenuViewModelViewDelegate diff --git a/Riot/Modules/SideMenu/SideMenuViewModel.swift b/Riot/Modules/SideMenu/SideMenuViewModel.swift index d1b013f97..0b0b84862 100644 --- a/Riot/Modules/SideMenu/SideMenuViewModel.swift +++ b/Riot/Modules/SideMenu/SideMenuViewModel.swift @@ -53,6 +53,8 @@ final class SideMenuViewModel: SideMenuViewModelType { self.loadData() case .tap(menuItem: let menuItem, sourceView: let sourceView): self.coordinatorDelegate?.sideMenuViewModel(self, didTapMenuItem: menuItem, fromSourceView: sourceView) + case .tapHeader(sourceView: let sourceView): + self.coordinatorDelegate?.sideMenuViewModel(self, didTapMenuItem: .settings, fromSourceView: sourceView) } } diff --git a/changelog.d/5500.change b/changelog.d/5500.change new file mode 100644 index 000000000..bcfe8ba8a --- /dev/null +++ b/changelog.d/5500.change @@ -0,0 +1 @@ +Change behaviour of avatar/self in left menu to match common paradigm and take user to their own profile/settings \ No newline at end of file From 0207ea9b70ad3a2c84974fdc46879b55563eecea Mon Sep 17 00:00:00 2001 From: SBiOSoftWhare Date: Thu, 17 Mar 2022 16:37:07 +0100 Subject: [PATCH 03/38] Add live location icon. --- .../live_location_icon.imageset/Contents.json | 26 ++++++++++++++++++ .../live_location_icon.png | Bin 0 -> 390 bytes .../live_location_icon@2x.png | Bin 0 -> 710 bytes .../live_location_icon@3x.png | Bin 0 -> 1110 bytes Riot/Generated/Images.swift | 1 + 5 files changed, 27 insertions(+) create mode 100644 Riot/Assets/Images.xcassets/Room/Location/live_location_icon.imageset/Contents.json create mode 100644 Riot/Assets/Images.xcassets/Room/Location/live_location_icon.imageset/live_location_icon.png create mode 100644 Riot/Assets/Images.xcassets/Room/Location/live_location_icon.imageset/live_location_icon@2x.png create mode 100644 Riot/Assets/Images.xcassets/Room/Location/live_location_icon.imageset/live_location_icon@3x.png diff --git a/Riot/Assets/Images.xcassets/Room/Location/live_location_icon.imageset/Contents.json b/Riot/Assets/Images.xcassets/Room/Location/live_location_icon.imageset/Contents.json new file mode 100644 index 000000000..e312c8132 --- /dev/null +++ b/Riot/Assets/Images.xcassets/Room/Location/live_location_icon.imageset/Contents.json @@ -0,0 +1,26 @@ +{ + "images" : [ + { + "filename" : "live_location_icon.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "live_location_icon@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "live_location_icon@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Riot/Assets/Images.xcassets/Room/Location/live_location_icon.imageset/live_location_icon.png b/Riot/Assets/Images.xcassets/Room/Location/live_location_icon.imageset/live_location_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..15d2d7b1110d046d5aa223b598e1cc825a50324e GIT binary patch literal 390 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM1|%Pp+x`GjoCO|{#S9GG!XV7ZFl!D-1!HlL zyA#8@b22Z19F}xPUq=Rpjs4tz5?O(A(w;7kAr*{ogD^1qP>Qb3^ zyH@c{tk8d)J*%TXt?NjX`Najrq9;Bpc7@#I-YH+fT#~7dw zzzQ};=BEzC(2}tV7J{frf60O}g0>TaGt9;aHa)W$s(^0VLYdrq5`Pf=FVa4dHa+@I zo6Y77bNCs3=63!!*KV23v-|U(bp0Xi$H4nuXV^>GW7#c0fN5u$4cMVzL+jm=eiQVs zIn^e{Y@`ypWi~g0O(*zL1C?U@3D@BP=F3|9318gv8>Xc=@(9-g^%AnAf2U}#a*PVT zI?^w$4Bf=fq-{w1iL}MLjN5!Cm~Q+S&R)l%NxFa>(|K&r2|OrHPNYpw1fZ!2k);4LP8;R)F(8)$>N6GWZB=>%~nNIHSEL6QmNPT+I`RIHdQ4S^se(Rb|~ z{!W*p1%e<5kRllX!!QiPFbu;m48t%C!!V3&AYwT@Esop|PXYfa&i0@$Pp)sU2CuNk zQyMq}ejsj!r}j^kdoTulccd@(>jMi1|t30!4mvva@3H4 zLC@!eV<;&%}cPrGD($67w-b0;S zhJ{Oz(S-L=A(QIJEu9?w#NB}$K`*c#v5zSSWjT-W;oc?0c7Ek)hv#W1yOr@eQRflX zBleLxh2w9 zgUmPU(*|5IendspbyATby~2j`1s)7;^@o^fvX#pJ#Dt-lO&s5&0BY zcEKjMbEo&u%4_1&cDd-$Ux?z2W6PC>YE!L&OkiYznLPl=M^SQ;Twri2)w0~j`L;^T zc_E7Y1zWCi{guU4&27{Bdh^Wm;LN1 z7PLddzk$r)cuNwtQ9};!@Bi*(IUf?*aUE#!O#g%A+j#3s35H=9hG7_nVHk#C7=~dO chVc*a8>6S`>n#7i!T Date: Thu, 17 Mar 2022 16:37:32 +0100 Subject: [PATCH 04/38] Add live location banner strings. --- Riot/Assets/en.lproj/Vector.strings | 5 +++++ Riot/Generated/Strings.swift | 8 ++++++++ 2 files changed, 13 insertions(+) diff --git a/Riot/Assets/en.lproj/Vector.strings b/Riot/Assets/en.lproj/Vector.strings index af5aa32be..5f0fae942 100644 --- a/Riot/Assets/en.lproj/Vector.strings +++ b/Riot/Assets/en.lproj/Vector.strings @@ -2088,6 +2088,11 @@ Tap the + to start adding people."; "location_sharing_settings_toggle_title" = "Enable location sharing"; +// MARK: Live location sharing + +"live_location_sharing_banner_title" = "Live location enabled"; +"live_location_sharing_banner_stop" = "Stop"; + // MARK: - MatrixKit diff --git a/Riot/Generated/Strings.swift b/Riot/Generated/Strings.swift index ca1a6125a..75d995cfa 100644 --- a/Riot/Generated/Strings.swift +++ b/Riot/Generated/Strings.swift @@ -2703,6 +2703,14 @@ public class VectorL10n: NSObject { public static var less: String { return VectorL10n.tr("Vector", "less") } + /// Stop + public static var liveLocationSharingBannerStop: String { + return VectorL10n.tr("Vector", "live_location_sharing_banner_stop") + } + /// Live location enabled + public static var liveLocationSharingBannerTitle: String { + return VectorL10n.tr("Vector", "live_location_sharing_banner_title") + } /// To discover contacts already using Matrix, %@ can send email addresses and phone numbers in your address book to your chosen Matrix identity server. Where supported, personal data is hashed before sending - please check your identity server's privacy policy for more details. public static func localContactsAccessDiscoveryWarning(_ p1: String) -> String { return VectorL10n.tr("Vector", "local_contacts_access_discovery_warning", p1) From b2dcb77cdfb223e87ce54ba2321928cb3c9997b2 Mon Sep 17 00:00:00 2001 From: SBiOSoftWhare Date: Thu, 17 Mar 2022 16:39:05 +0100 Subject: [PATCH 05/38] UIButton: Add convenient method to modify font. --- Riot/Categories/UIButton.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Riot/Categories/UIButton.swift b/Riot/Categories/UIButton.swift index 6f896bb58..195a2c244 100644 --- a/Riot/Categories/UIButton.swift +++ b/Riot/Categories/UIButton.swift @@ -14,7 +14,7 @@ limitations under the License. */ -import Foundation +import UIKit extension UIButton { @@ -51,4 +51,10 @@ extension UIButton { self.titleLabel?.adjustsFontForContentSizeCategory = newValue } } + + /// Set title font and enable Dynamic Type support + func vc_setTitleFont(_ font: UIFont) { + self.vc_adjustsFontForContentSizeCategory = true + self.titleLabel?.font = font + } } From 8f8461a978de433333321b20587ecd38c4b32c99 Mon Sep 17 00:00:00 2001 From: SBiOSoftWhare Date: Thu, 17 Mar 2022 16:48:10 +0100 Subject: [PATCH 06/38] Create LiveLocationSharingBannerView. --- .../LiveLocationSharingBannerView.swift | 96 +++++++++++++++++++ .../LiveLocationSharingBannerView.xib | 72 ++++++++++++++ 2 files changed, 168 insertions(+) create mode 100644 Riot/Modules/Room/LocationSharing/LiveLocationSharingBannerView.swift create mode 100644 Riot/Modules/Room/LocationSharing/LiveLocationSharingBannerView.xib diff --git a/Riot/Modules/Room/LocationSharing/LiveLocationSharingBannerView.swift b/Riot/Modules/Room/LocationSharing/LiveLocationSharingBannerView.swift new file mode 100644 index 000000000..b6ad5e1c5 --- /dev/null +++ b/Riot/Modules/Room/LocationSharing/LiveLocationSharingBannerView.swift @@ -0,0 +1,96 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 Reusable +import UIKit + +@objcMembers +final class LiveLocationSharingBannerView: UIView, NibLoadable, Themable { + + // MARK: - Properties + + // MARK: Outlets + + @IBOutlet private weak var iconImageView: UIImageView! + @IBOutlet private weak var titleLabel: UILabel! + @IBOutlet private weak var stopButton: UIButton! + + // MARK: Private + + private var theme: Theme! + + // MARK: Public + + var didTapBackground: (() -> Void)? + var didTapStopButton: (() -> Void)? + + // MARK: - Setup + + static func instantiate() -> LiveLocationSharingBannerView { + let view = LiveLocationSharingBannerView.loadFromNib() + view.update(theme: ThemeService.shared().theme) + return view + } + + // MARK: - Life cycle + + override func awakeFromNib() { + super.awakeFromNib() + + self.setupBackgroundTapGestureRecognizer() + + self.titleLabel.text = VectorL10n.liveLocationSharingBannerTitle + self.stopButton.setTitle(VectorL10n.liveLocationSharingBannerStop, for: .normal) + } + + // MARK: - Public + + func update(theme: Theme) { + self.theme = theme + + let tintColor = theme.colors.background + + self.backgroundColor = theme.tintColor + + self.iconImageView.tintColor = tintColor + + self.titleLabel.textColor = tintColor + self.titleLabel.font = theme.fonts.footnote + + self.stopButton.vc_setTitleFont(theme.fonts.footnote) + self.stopButton.tintColor = tintColor + self.stopButton.setTitleColor(tintColor, for: .normal) + self.stopButton.setTitleColor(tintColor.withAlphaComponent(0.5), for: .highlighted) + } + + // MARK: - Private + + private func setupBackgroundTapGestureRecognizer() { + let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleBackgroundViewTap(_:))) + self.addGestureRecognizer(tapGestureRecognizer) + } + + // MARK: - Actions + + @objc private func handleBackgroundViewTap(_ gestureRecognizer: UITapGestureRecognizer) { + self.didTapBackground?() + } + + @IBAction private func stopButtonAction(_ sender: Any) { + self.didTapStopButton?() + } +} diff --git a/Riot/Modules/Room/LocationSharing/LiveLocationSharingBannerView.xib b/Riot/Modules/Room/LocationSharing/LiveLocationSharingBannerView.xib new file mode 100644 index 000000000..f6c67f287 --- /dev/null +++ b/Riot/Modules/Room/LocationSharing/LiveLocationSharingBannerView.xib @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 35cbfc757a6ec27858c7a9f32d00ab0551c0f662 Mon Sep 17 00:00:00 2001 From: SBiOSoftWhare Date: Thu, 17 Mar 2022 16:58:41 +0100 Subject: [PATCH 07/38] RoomVC: Handle live location banner. --- Riot/Modules/Room/RoomViewController.h | 6 +++ Riot/Modules/Room/RoomViewController.m | 50 +++++++++++++++++++++++- Riot/Modules/Room/RoomViewController.xib | 28 +++++++++++-- 3 files changed, 79 insertions(+), 5 deletions(-) diff --git a/Riot/Modules/Room/RoomViewController.h b/Riot/Modules/Room/RoomViewController.h index de52c4978..1dcb69ece 100644 --- a/Riot/Modules/Room/RoomViewController.h +++ b/Riot/Modules/Room/RoomViewController.h @@ -277,6 +277,12 @@ didRequestEditForPollWithStartEvent:(MXEvent *)startEvent; */ - (void)roomViewControllerDidStopLoading:(RoomViewController *)roomViewController; +/// User tap live location sharing stop action +- (void)roomViewControllerDidStopLiveLocationSharing:(RoomViewController *)roomViewController; + +/// User tap live location sharing banner +- (void)roomViewControllerDidTapLiveLocationSharingBanner:(RoomViewController *)roomViewController; + @end NS_ASSUME_NONNULL_END diff --git a/Riot/Modules/Room/RoomViewController.m b/Riot/Modules/Room/RoomViewController.m index 12fb57ec8..75a21b43c 100644 --- a/Riot/Modules/Room/RoomViewController.m +++ b/Riot/Modules/Room/RoomViewController.m @@ -223,6 +223,13 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; // scroll state just before the layout change, and restore it after the layout. @property (nonatomic) BOOL shouldScrollToBottomAfterLayout; +/// Handles all banners that should be displayed at the top of the timeline but that should not scroll with the timeline +@property (weak, nonatomic, nullable) IBOutlet UIStackView *topBannersStackView; + +@property (nonatomic) BOOL shouldShowLiveLocationSharingBannerView; + +@property (nonatomic, weak) LiveLocationSharingBannerView *liveLocationSharingBannerView; + @end @implementation RoomViewController @@ -2380,6 +2387,18 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; [suggestionsViewController didMoveToParentViewController:self]; } +- (void)updateTopBanners +{ + [self.view bringSubviewToFront:self.topBannersStackView]; + + [self.topBannersStackView vc_removeAllSubviews]; + + if (self.shouldShowLiveLocationSharingBannerView) + { + [self showLiveLocationBannerView]; + } +} + #pragma mark - Jitsi - (void)showJitsiCallWithWidget:(Widget*)widget @@ -7323,5 +7342,34 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; [self stopActivityIndicator]; } -@end +#pragma mark - Live location sharing +- (void)showLiveLocationBannerView +{ + if (self.liveLocationSharingBannerView) + { + return; + } + + LiveLocationSharingBannerView *bannerView = [LiveLocationSharingBannerView instantiate]; + + [bannerView updateWithTheme:ThemeService.shared.theme]; + + MXWeakify(self); + + bannerView.didTapBackground = ^{ + MXStrongifyAndReturnIfNil(self); + [self.delegate roomViewControllerDidTapLiveLocationSharingBanner:self]; + }; + + bannerView.didTapStopButton = ^{ + MXStrongifyAndReturnIfNil(self); + [self.delegate roomViewControllerDidStopLiveLocationSharing:self]; + }; + + [self.topBannersStackView addArrangedSubview:bannerView]; + + self.liveLocationSharingBannerView = bannerView; +} + +@end diff --git a/Riot/Modules/Room/RoomViewController.xib b/Riot/Modules/Room/RoomViewController.xib index e8b8bfd6e..783146c29 100644 --- a/Riot/Modules/Room/RoomViewController.xib +++ b/Riot/Modules/Room/RoomViewController.xib @@ -13,7 +13,6 @@ - @@ -32,6 +31,7 @@ + @@ -41,8 +41,25 @@ - - + + + + + + + + + + + + + @@ -192,6 +209,7 @@ + @@ -200,17 +218,19 @@ + + - + From 63654b7137c389bb70cdbb4aecd8398ceac7d3ff Mon Sep 17 00:00:00 2001 From: SBiOSoftWhare Date: Thu, 17 Mar 2022 16:59:30 +0100 Subject: [PATCH 08/38] RoomCoordinator: Update with new RoomViewControllerDelegate methods. --- Riot/Modules/Room/RoomCoordinator.swift | 8 ++++++++ .../Spaces/SpaceRoomList/ExploreRoomCoordinator.swift | 7 +++++++ 2 files changed, 15 insertions(+) diff --git a/Riot/Modules/Room/RoomCoordinator.swift b/Riot/Modules/Room/RoomCoordinator.swift index 84fd3ab62..c3799610a 100644 --- a/Riot/Modules/Room/RoomCoordinator.swift +++ b/Riot/Modules/Room/RoomCoordinator.swift @@ -459,4 +459,12 @@ extension RoomCoordinator: RoomViewControllerDelegate { func roomViewControllerDidStopLoading(_ roomViewController: RoomViewController) { stopLoading() } + + func roomViewControllerDidTapLiveLocationSharingBanner(_ roomViewController: RoomViewController) { + // TODO: + } + + func roomViewControllerDidStopLiveLocationSharing(_ roomViewController: RoomViewController) { + // TODO: + } } diff --git a/Riot/Modules/Spaces/SpaceRoomList/ExploreRoomCoordinator.swift b/Riot/Modules/Spaces/SpaceRoomList/ExploreRoomCoordinator.swift index 276edf745..8cd8cc824 100644 --- a/Riot/Modules/Spaces/SpaceRoomList/ExploreRoomCoordinator.swift +++ b/Riot/Modules/Spaces/SpaceRoomList/ExploreRoomCoordinator.swift @@ -474,6 +474,13 @@ extension ExploreRoomCoordinator: RoomViewControllerDelegate { } + func roomViewControllerDidTapLiveLocationSharingBanner(_ roomViewController: RoomViewController) { + // TODO: + } + + func roomViewControllerDidStopLiveLocationSharing(_ roomViewController: RoomViewController) { + // TODO: + } } // MARK: - ContactsPickerCoordinatorDelegate From 9879319a3806c8c16f135d10fe66753df4df12db Mon Sep 17 00:00:00 2001 From: SBiOSoftWhare Date: Thu, 17 Mar 2022 17:02:29 +0100 Subject: [PATCH 09/38] Update changes --- changelog.d/5857.wip | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/5857.wip diff --git a/changelog.d/5857.wip b/changelog.d/5857.wip new file mode 100644 index 000000000..80369f90c --- /dev/null +++ b/changelog.d/5857.wip @@ -0,0 +1 @@ +Location sharing: Handle live location banner view in room screen. \ No newline at end of file From e699d91c5b04104e2486ce51d260e67b85550aeb Mon Sep 17 00:00:00 2001 From: SBiOSoftWhare Date: Thu, 17 Mar 2022 17:34:09 +0100 Subject: [PATCH 10/38] RoomVC: Handle theme change for live location banner. --- Riot/Modules/Room/RoomViewController.m | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Riot/Modules/Room/RoomViewController.m b/Riot/Modules/Room/RoomViewController.m index 75a21b43c..caa9286bc 100644 --- a/Riot/Modules/Room/RoomViewController.m +++ b/Riot/Modules/Room/RoomViewController.m @@ -470,6 +470,8 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; [self updateThreadListBarButtonBadgeWith:self.mainSession.threadingService]; [threadListBarButtonItem updateWithTheme:ThemeService.shared.theme]; + [self.liveLocationSharingBannerView updateWithTheme:ThemeService.shared.theme]; + [self setNeedsStatusBarAppearanceUpdate]; } From 0e00c45849813b042c0f73f823a5b37a30f05d51 Mon Sep 17 00:00:00 2001 From: ismailgulek Date: Fri, 18 Mar 2022 00:55:09 +0300 Subject: [PATCH 11/38] Add icons for thread badges --- .../Contents.json | 15 +++++++++++++++ .../dark-theme-no-mentions.svg | 1 + .../Contents.json | 15 +++++++++++++++ .../light-theme-no-mentions.svg | 1 + .../threads_icon_red_dot.imageset/Contents.json | 15 +++++++++++++++ .../light-and-dark-theme-mentions.svg | 1 + Riot/Generated/Images.swift | 3 +++ 7 files changed, 51 insertions(+) create mode 100644 Riot/Assets/Images.xcassets/Room/Threads/threads_icon_gray_dot_dark.imageset/Contents.json create mode 100644 Riot/Assets/Images.xcassets/Room/Threads/threads_icon_gray_dot_dark.imageset/dark-theme-no-mentions.svg create mode 100644 Riot/Assets/Images.xcassets/Room/Threads/threads_icon_gray_dot_light.imageset/Contents.json create mode 100644 Riot/Assets/Images.xcassets/Room/Threads/threads_icon_gray_dot_light.imageset/light-theme-no-mentions.svg create mode 100644 Riot/Assets/Images.xcassets/Room/Threads/threads_icon_red_dot.imageset/Contents.json create mode 100644 Riot/Assets/Images.xcassets/Room/Threads/threads_icon_red_dot.imageset/light-and-dark-theme-mentions.svg diff --git a/Riot/Assets/Images.xcassets/Room/Threads/threads_icon_gray_dot_dark.imageset/Contents.json b/Riot/Assets/Images.xcassets/Room/Threads/threads_icon_gray_dot_dark.imageset/Contents.json new file mode 100644 index 000000000..de2178f44 --- /dev/null +++ b/Riot/Assets/Images.xcassets/Room/Threads/threads_icon_gray_dot_dark.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "dark-theme-no-mentions.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "original" + } +} diff --git a/Riot/Assets/Images.xcassets/Room/Threads/threads_icon_gray_dot_dark.imageset/dark-theme-no-mentions.svg b/Riot/Assets/Images.xcassets/Room/Threads/threads_icon_gray_dot_dark.imageset/dark-theme-no-mentions.svg new file mode 100644 index 000000000..ae6aa847b --- /dev/null +++ b/Riot/Assets/Images.xcassets/Room/Threads/threads_icon_gray_dot_dark.imageset/dark-theme-no-mentions.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Riot/Assets/Images.xcassets/Room/Threads/threads_icon_gray_dot_light.imageset/Contents.json b/Riot/Assets/Images.xcassets/Room/Threads/threads_icon_gray_dot_light.imageset/Contents.json new file mode 100644 index 000000000..9d412b77e --- /dev/null +++ b/Riot/Assets/Images.xcassets/Room/Threads/threads_icon_gray_dot_light.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "light-theme-no-mentions.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "original" + } +} diff --git a/Riot/Assets/Images.xcassets/Room/Threads/threads_icon_gray_dot_light.imageset/light-theme-no-mentions.svg b/Riot/Assets/Images.xcassets/Room/Threads/threads_icon_gray_dot_light.imageset/light-theme-no-mentions.svg new file mode 100644 index 000000000..f8468cbd2 --- /dev/null +++ b/Riot/Assets/Images.xcassets/Room/Threads/threads_icon_gray_dot_light.imageset/light-theme-no-mentions.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Riot/Assets/Images.xcassets/Room/Threads/threads_icon_red_dot.imageset/Contents.json b/Riot/Assets/Images.xcassets/Room/Threads/threads_icon_red_dot.imageset/Contents.json new file mode 100644 index 000000000..dd53ab236 --- /dev/null +++ b/Riot/Assets/Images.xcassets/Room/Threads/threads_icon_red_dot.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "light-and-dark-theme-mentions.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "original" + } +} diff --git a/Riot/Assets/Images.xcassets/Room/Threads/threads_icon_red_dot.imageset/light-and-dark-theme-mentions.svg b/Riot/Assets/Images.xcassets/Room/Threads/threads_icon_red_dot.imageset/light-and-dark-theme-mentions.svg new file mode 100644 index 000000000..2bf8d2125 --- /dev/null +++ b/Riot/Assets/Images.xcassets/Room/Threads/threads_icon_red_dot.imageset/light-and-dark-theme-mentions.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Riot/Generated/Images.swift b/Riot/Generated/Images.swift index df3b02a27..b4edc6beb 100644 --- a/Riot/Generated/Images.swift +++ b/Riot/Generated/Images.swift @@ -184,6 +184,9 @@ internal class Asset: NSObject { internal static let threadsFilter = ImageAsset(name: "threads_filter") internal static let threadsFilterApplied = ImageAsset(name: "threads_filter_applied") internal static let threadsIcon = ImageAsset(name: "threads_icon") + internal static let threadsIconGrayDotDark = ImageAsset(name: "threads_icon_gray_dot_dark") + internal static let threadsIconGrayDotLight = ImageAsset(name: "threads_icon_gray_dot_light") + internal static let threadsIconRedDot = ImageAsset(name: "threads_icon_red_dot") internal static let urlPreviewClose = ImageAsset(name: "url_preview_close") internal static let urlPreviewCloseDark = ImageAsset(name: "url_preview_close_dark") internal static let voiceMessageCancelGradient = ImageAsset(name: "voice_message_cancel_gradient") From 9481f391ce01319480eeadded11f6b49a859b209 Mon Sep 17 00:00:00 2001 From: ismailgulek Date: Fri, 18 Mar 2022 02:08:03 +0300 Subject: [PATCH 12/38] Update thread list bar button item badge --- Riot/Modules/Room/RoomViewController.m | 109 ++++++++++++++++++------- 1 file changed, 79 insertions(+), 30 deletions(-) diff --git a/Riot/Modules/Room/RoomViewController.m b/Riot/Modules/Room/RoomViewController.m index 12fb57ec8..544d5ca05 100644 --- a/Riot/Modules/Room/RoomViewController.m +++ b/Riot/Modules/Room/RoomViewController.m @@ -89,6 +89,10 @@ NSNotificationName const RoomCallTileTappedNotification = @"RoomCallTileTappedNotification"; NSNotificationName const RoomGroupCallTileTappedNotification = @"RoomGroupCallTileTappedNotification"; const NSTimeInterval kResizeComposerAnimationDuration = .05; +static const int kThreadListBarButtonItemTag = 99; +static UIEdgeInsets kThreadListBarButtonItemContentInsetsNoDot; +static UIEdgeInsets kThreadListBarButtonItemContentInsetsDot; +static CGSize kThreadListBarButtonItemImageSize; @interface RoomViewController () 0) { - threadListBarButtonItem.badgeText = [self threadListBadgeTextFor:notificationsCount.numberOfHighlightedThreads]; - threadListBarButtonItem.badgeBackgroundColor = ThemeService.shared.theme.colors.alert; + [button setImage:AssetImages.threadsIconRedDot.image + forState:UIControlStateNormal]; + button.contentEdgeInsets = kThreadListBarButtonItemContentInsetsDot; } else if (notificationsCount.numberOfNotifiedThreads > 0) { - threadListBarButtonItem.badgeText = [self threadListBadgeTextFor:notificationsCount.numberOfNotifiedThreads]; - threadListBarButtonItem.badgeBackgroundColor = ThemeService.shared.theme.noticeSecondaryColor; + if (ThemeService.shared.isCurrentThemeDark) + { + [button setImage:AssetImages.threadsIconGrayDotDark.image + forState:UIControlStateNormal]; + } + else + { + [button setImage:AssetImages.threadsIconGrayDotLight.image + forState:UIControlStateNormal]; + } + button.contentEdgeInsets = kThreadListBarButtonItemContentInsetsDot; } else { - // remove badge - threadListBarButtonItem.badgeText = nil; + [button setImage:[AssetImages.threadsIcon.image vc_resizedWith:kThreadListBarButtonItemImageSize] + forState:UIControlStateNormal]; + button.contentEdgeInsets = kThreadListBarButtonItemContentInsetsNoDot; } -} -- (NSString *)threadListBadgeTextFor:(NSUInteger)numberOfThreads -{ - if (numberOfThreads < 100) + if (replaceIndex == NSNotFound) { - return [NSString stringWithFormat:@"%tu", numberOfThreads]; + // there is no thread list bar button item, this was only an update + return; } - else + + UIBarButtonItem *originalItem = self.navigationItem.rightBarButtonItems[replaceIndex]; + UIButton *originalButton = (UIButton *)originalItem.customView; + if ([originalButton imageForState:UIControlStateNormal] == [button imageForState:UIControlStateNormal] + && UIEdgeInsetsEqualToEdgeInsets(originalButton.contentEdgeInsets, button.contentEdgeInsets)) { - return @"ยทยทยท"; + // no need to replace, it's the same + return; } + NSMutableArray *items = [self.navigationItem.rightBarButtonItems mutableCopy]; + items[replaceIndex] = threadListBarButtonItem; + self.navigationItem.rightBarButtonItems = items; } #pragma mark - RoomContextualMenuViewControllerDelegate From d6df9a6a2de42524048ef1be634b2a9003cf4de9 Mon Sep 17 00:00:00 2001 From: ismailgulek Date: Fri, 18 Mar 2022 02:08:38 +0300 Subject: [PATCH 13/38] Add changelog --- changelog.d/5853.change | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/5853.change diff --git a/changelog.d/5853.change b/changelog.d/5853.change new file mode 100644 index 000000000..c35b4255a --- /dev/null +++ b/changelog.d/5853.change @@ -0,0 +1 @@ +RoomViewController: Remove thread list bar button item badge count. From 7a8114bec3b3cedc1e3f2a14b4523f616f98a646 Mon Sep 17 00:00:00 2001 From: Andy Uhnak Date: Fri, 18 Mar 2022 12:14:40 +0000 Subject: [PATCH 14/38] Allow ignoring invited users that have not joined a room yet --- Riot/Modules/Contacts/Details/ContactDetailsViewController.m | 2 +- .../Room/Members/Detail/RoomMemberDetailsViewController.m | 4 ++-- changelog.d/5866.change | 1 + 3 files changed, 4 insertions(+), 3 deletions(-) create mode 100644 changelog.d/5866.change diff --git a/Riot/Modules/Contacts/Details/ContactDetailsViewController.m b/Riot/Modules/Contacts/Details/ContactDetailsViewController.m index bc1815a41..ac0661eda 100644 --- a/Riot/Modules/Contacts/Details/ContactDetailsViewController.m +++ b/Riot/Modules/Contacts/Details/ContactDetailsViewController.m @@ -671,7 +671,7 @@ switch (action) { case ContactDetailsActionIgnore: - title = [VectorL10n roomParticipantsActionIgnore]; + title = [VectorL10n ignoreUser]; break; case ContactDetailsActionUnignore: title = [VectorL10n roomParticipantsActionUnignore]; diff --git a/Riot/Modules/Room/Members/Detail/RoomMemberDetailsViewController.m b/Riot/Modules/Room/Members/Detail/RoomMemberDetailsViewController.m index 5fd62ecad..68011b5c1 100644 --- a/Riot/Modules/Room/Members/Detail/RoomMemberDetailsViewController.m +++ b/Riot/Modules/Room/Members/Detail/RoomMemberDetailsViewController.m @@ -629,7 +629,7 @@ } // Check whether the option Ignore may be presented - if (RiotSettings.shared.roomMemberScreenShowIgnore && self.mxRoomMember.membership == MXMembershipJoin) + if (RiotSettings.shared.roomMemberScreenShowIgnore) { // is he already ignored ? if (![self.mainSession isUserIgnored:self.mxRoomMember.userId]) @@ -778,7 +778,7 @@ title = [VectorL10n roomParticipantsActionUnban]; break; case MXKRoomMemberDetailsActionIgnore: - title = [VectorL10n roomParticipantsActionIgnore]; + title = [VectorL10n ignoreUser]; break; case MXKRoomMemberDetailsActionUnignore: title = [VectorL10n roomParticipantsActionUnignore]; diff --git a/changelog.d/5866.change b/changelog.d/5866.change new file mode 100644 index 000000000..776ccc919 --- /dev/null +++ b/changelog.d/5866.change @@ -0,0 +1 @@ +Room: Allow ignoring invited users that have not joined a room yet From 8877259c18214723cc8db193c36d89d47dfcd461 Mon Sep 17 00:00:00 2001 From: Andy Uhnak Date: Fri, 18 Mar 2022 14:02:50 +0000 Subject: [PATCH 15/38] Revert copy change --- Riot/Modules/Contacts/Details/ContactDetailsViewController.m | 2 +- .../Room/Members/Detail/RoomMemberDetailsViewController.m | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Riot/Modules/Contacts/Details/ContactDetailsViewController.m b/Riot/Modules/Contacts/Details/ContactDetailsViewController.m index ac0661eda..bc1815a41 100644 --- a/Riot/Modules/Contacts/Details/ContactDetailsViewController.m +++ b/Riot/Modules/Contacts/Details/ContactDetailsViewController.m @@ -671,7 +671,7 @@ switch (action) { case ContactDetailsActionIgnore: - title = [VectorL10n ignoreUser]; + title = [VectorL10n roomParticipantsActionIgnore]; break; case ContactDetailsActionUnignore: title = [VectorL10n roomParticipantsActionUnignore]; diff --git a/Riot/Modules/Room/Members/Detail/RoomMemberDetailsViewController.m b/Riot/Modules/Room/Members/Detail/RoomMemberDetailsViewController.m index 68011b5c1..2648fc89c 100644 --- a/Riot/Modules/Room/Members/Detail/RoomMemberDetailsViewController.m +++ b/Riot/Modules/Room/Members/Detail/RoomMemberDetailsViewController.m @@ -778,7 +778,7 @@ title = [VectorL10n roomParticipantsActionUnban]; break; case MXKRoomMemberDetailsActionIgnore: - title = [VectorL10n ignoreUser]; + title = [VectorL10n roomParticipantsActionIgnore]; break; case MXKRoomMemberDetailsActionUnignore: title = [VectorL10n roomParticipantsActionUnignore]; From 143c19e185372c0befe0f7afa234c017df491f63 Mon Sep 17 00:00:00 2001 From: Andy Uhnak Date: Fri, 18 Mar 2022 15:26:30 +0000 Subject: [PATCH 16/38] Fix broken indicators on iPad --- Riot/Modules/SplitView/SplitViewCoordinator.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Riot/Modules/SplitView/SplitViewCoordinator.swift b/Riot/Modules/SplitView/SplitViewCoordinator.swift index 6b18a0ded..17bfd2dfc 100644 --- a/Riot/Modules/SplitView/SplitViewCoordinator.swift +++ b/Riot/Modules/SplitView/SplitViewCoordinator.swift @@ -103,8 +103,6 @@ final class SplitViewCoordinator: NSObject, SplitViewCoordinatorType { // Setup split view controller self.splitViewController.viewControllers = [tabBarCoordinator.toPresentable(), detailNavigationController] - - updateUserIndicatorPresenter() self.add(childCoordinator: tabBarCoordinator) @@ -113,6 +111,8 @@ final class SplitViewCoordinator: NSObject, SplitViewCoordinatorType { self.detailNavigationController = detailNavigationController self.detailNavigationRouter = NavigationRouter(navigationController: detailNavigationController) + updateUserIndicatorPresenter() + self.parameters.router.setRootModule(self.splitViewController) self.registerNavigationRouterNotifications() From 0039eb40ff0d89fe61246e542643e5f850042a09 Mon Sep 17 00:00:00 2001 From: Andy Uhnak Date: Fri, 18 Mar 2022 15:58:55 +0000 Subject: [PATCH 17/38] version++ --- CHANGES.md | 7 +++++++ Config/AppVersion.xcconfig | 4 ++-- changelog.d/5866.change | 1 - 3 files changed, 9 insertions(+), 3 deletions(-) delete mode 100644 changelog.d/5866.change diff --git a/CHANGES.md b/CHANGES.md index 53b527969..040d7e5b4 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,10 @@ +## Changes in 1.8.7 (2022-03-18) + +๐Ÿ™Œ Improvements + +- Room: Allow ignoring invited users that have not joined a room yet ([#5866](https://github.com/vector-im/element-ios/issues/5866)) + + ## Changes in 1.8.6 (2022-03-14) ๐Ÿ™Œ Improvements diff --git a/Config/AppVersion.xcconfig b/Config/AppVersion.xcconfig index 174e0bd5b..164b076c5 100644 --- a/Config/AppVersion.xcconfig +++ b/Config/AppVersion.xcconfig @@ -15,5 +15,5 @@ // // Version -MARKETING_VERSION = 1.8.6 -CURRENT_PROJECT_VERSION = 1.8.6 +MARKETING_VERSION = 1.8.7 +CURRENT_PROJECT_VERSION = 1.8.7 diff --git a/changelog.d/5866.change b/changelog.d/5866.change deleted file mode 100644 index 776ccc919..000000000 --- a/changelog.d/5866.change +++ /dev/null @@ -1 +0,0 @@ -Room: Allow ignoring invited users that have not joined a room yet From 1be891f17a78d7144a4af67f940b66ec7c3c716f Mon Sep 17 00:00:00 2001 From: Andy Uhnak Date: Fri, 18 Mar 2022 16:56:05 +0000 Subject: [PATCH 18/38] finish version++ From e995dc0fe85bcf59968fe7082956ad13dbff4b0b Mon Sep 17 00:00:00 2001 From: Andy Uhnak Date: Fri, 18 Mar 2022 17:07:35 +0000 Subject: [PATCH 19/38] Prepare for new sprint --- Config/AppVersion.xcconfig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Config/AppVersion.xcconfig b/Config/AppVersion.xcconfig index 164b076c5..e2f771c27 100644 --- a/Config/AppVersion.xcconfig +++ b/Config/AppVersion.xcconfig @@ -15,5 +15,5 @@ // // Version -MARKETING_VERSION = 1.8.7 -CURRENT_PROJECT_VERSION = 1.8.7 +MARKETING_VERSION = 1.8.8 +CURRENT_PROJECT_VERSION = 1.8.8 From dd5860ee55efbcff2b6c8c7354fd30bf73b59f95 Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Fri, 18 Mar 2022 08:47:09 +0100 Subject: [PATCH 20/38] Fix retain cycle in UserSuggestionViewModel Steps to reproduce: - Launch app - Enter and leave a bunch of rooms - Fire up the memory graph debugger - Filter for `UserSuggestionViewModel` Relates to: #5058 Signed-off-by: Johannes Marbach --- .../Modules/Room/UserSuggestion/UserSuggestionViewModel.swift | 4 ++-- changelog.d/5058.bugfix | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) create mode 100644 changelog.d/5058.bugfix diff --git a/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionViewModel.swift b/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionViewModel.swift index 51a3aa7f7..da4e3fbad 100644 --- a/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionViewModel.swift +++ b/RiotSwiftUI/Modules/Room/UserSuggestion/UserSuggestionViewModel.swift @@ -46,8 +46,8 @@ class UserSuggestionViewModel: UserSuggestionViewModelType, UserSuggestionViewMo super.init(initialViewState: UserSuggestionViewState(items: items)) - userSuggestionService.items.sink { items in - self.state.items = items.map({ item in + userSuggestionService.items.sink { [weak self] items in + self?.state.items = items.map({ item in UserSuggestionViewStateItem(id: item.userId, avatar: item, displayName: item.displayName) }) }.store(in: &cancellables) diff --git a/changelog.d/5058.bugfix b/changelog.d/5058.bugfix new file mode 100644 index 000000000..c1bba1101 --- /dev/null +++ b/changelog.d/5058.bugfix @@ -0,0 +1 @@ +UserSuggestionViewModel: Fix retain cycle From 0d9946897f1bdd3ac334444d6bb52870d2834b49 Mon Sep 17 00:00:00 2001 From: Doug Date: Fri, 11 Mar 2022 14:59:57 +0000 Subject: [PATCH 21/38] Remove NSException from RiotShareExtension Fix some retain cycles and early releases. --- RiotShareExtension/Shared/ShareManager.m | 14 +++++++++++--- .../Sources/ShareExtensionRootViewController.m | 4 ---- RiotShareExtension/Sources/ShareItemSender.m | 2 +- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/RiotShareExtension/Shared/ShareManager.m b/RiotShareExtension/Shared/ShareManager.m index ad7a75ad3..ffb94fc8c 100644 --- a/RiotShareExtension/Shared/ShareManager.m +++ b/RiotShareExtension/Shared/ShareManager.m @@ -34,6 +34,12 @@ @property (nonatomic, strong) MXKAccount *userAccount; @property (nonatomic, strong) MXFileStore *fileStore; +/** + An array of rooms that the item is being shared to. This is to maintain a strong ref + to all necessary `MXRoom`s until sharing has completed. + */ +@property (nonatomic, strong) NSMutableArray *selectedRooms; + @end @@ -94,17 +100,19 @@ session.crypto.warnOnUnknowDevices = NO; // Do not warn for unknown devices. We have cross-signing now - NSMutableArray *rooms = [NSMutableArray array]; + self.selectedRooms = [NSMutableArray array]; for (NSString *roomIdentifier in roomIdentifiers) { MXRoom *room = [MXRoom loadRoomFromStore:self.fileStore withRoomId:roomIdentifier matrixSession:session]; if (room) { - [rooms addObject:room]; + [self.selectedRooms addObject:room]; } } - [self.shareItemSender sendItemsToRooms:rooms success:^{ + [self.shareItemSender sendItemsToRooms:self.selectedRooms success:^{ + self.selectedRooms = nil; self.completionCallback(ShareManagerResultFinished); } failure:^(NSArray *errors) { + self.selectedRooms = nil; [self showFailureAlert:[VectorL10n roomEventFailedToSend]]; }]; diff --git a/RiotShareExtension/Sources/ShareExtensionRootViewController.m b/RiotShareExtension/Sources/ShareExtensionRootViewController.m index c43eef81e..5c6421551 100644 --- a/RiotShareExtension/Sources/ShareExtensionRootViewController.m +++ b/RiotShareExtension/Sources/ShareExtensionRootViewController.m @@ -99,10 +99,6 @@ { [self dismissViewControllerAnimated:true completion:^{ [self.presentingViewController dismissViewControllerAnimated:false completion:nil]; - - // FIXME: Share extension memory usage increase when launched several times and then crash due to some memory leaks. - // For now, we force the share extension to exit and free memory. - [NSException raise:@"Kill the app extension" format:@"Free memory used by share extension"]; }]; } diff --git a/RiotShareExtension/Sources/ShareItemSender.m b/RiotShareExtension/Sources/ShareItemSender.m index 1758622ec..69f925a28 100644 --- a/RiotShareExtension/Sources/ShareItemSender.m +++ b/RiotShareExtension/Sources/ShareItemSender.m @@ -35,7 +35,7 @@ typedef NS_ENUM(NSInteger, ImageCompressionMode) @interface ShareItemSender () -@property (nonatomic, strong, readonly) UIViewController *rootViewController; +@property (nonatomic, weak, readonly) UIViewController *rootViewController; @property (nonatomic, strong, readonly) ShareExtensionShareItemProvider *shareItemProvider; @property (nonatomic, strong, readonly) NSMutableArray *pendingImages; From 55795493dd4e30984f08e7d98c012c3d8dd1182b Mon Sep 17 00:00:00 2001 From: Doug Date: Wed, 16 Mar 2022 14:40:57 +0000 Subject: [PATCH 22/38] Store the MXSession in ShareDataSource to prevent multiple copies being created. --- RiotShareExtension/Shared/ShareDataSource.h | 2 +- RiotShareExtension/Shared/ShareDataSource.m | 14 +++------- RiotShareExtension/Shared/ShareManager.m | 30 +++++++++++++++++++-- 3 files changed, 33 insertions(+), 13 deletions(-) diff --git a/RiotShareExtension/Shared/ShareDataSource.h b/RiotShareExtension/Shared/ShareDataSource.h index 9dc36d5f5..92aef0c1d 100644 --- a/RiotShareExtension/Shared/ShareDataSource.h +++ b/RiotShareExtension/Shared/ShareDataSource.h @@ -31,7 +31,7 @@ @property (nonatomic, strong, readonly) NSSet *selectedRoomIdentifiers; - (instancetype)initWithFileStore:(MXFileStore *)fileStore - credentials:(MXCredentials *)credentials; + session:(MXSession *)session; - (void)selectRoomWithIdentifier:(NSString *)roomIdentifier animated:(BOOL)animated; diff --git a/RiotShareExtension/Shared/ShareDataSource.m b/RiotShareExtension/Shared/ShareDataSource.m index 83889c333..e097b5da7 100644 --- a/RiotShareExtension/Shared/ShareDataSource.m +++ b/RiotShareExtension/Shared/ShareDataSource.m @@ -20,7 +20,7 @@ @interface ShareDataSource () @property (nonatomic, strong, readonly) MXFileStore *fileStore; -@property (nonatomic, strong, readonly) MXCredentials *credentials; +@property (nonatomic, strong, readonly) MXSession *session; @property NSArray *recentCellDatas; @property NSMutableArray *visibleRoomCellDatas; @@ -32,12 +32,12 @@ @implementation ShareDataSource - (instancetype)initWithFileStore:(MXFileStore *)fileStore - credentials:(MXCredentials *)credentials + session:(MXSession *)session { if (self = [super init]) { _fileStore = fileStore; - _credentials = credentials; + _session = session; _internalSelectedRoomIdentifiers = [NSMutableSet set]; @@ -81,19 +81,13 @@ NSMutableArray *cellData = [NSMutableArray array]; - MXRestClient *mxRestClient = [[MXRestClient alloc] initWithCredentials:self.credentials andOnUnrecognizedCertificateBlock:nil andPersistentTokenDataHandler:^(void (^handler)(NSArray *credentials, void (^completion)(BOOL didUpdateCredentials))) { - [[MXKAccountManager sharedManager] readAndWriteCredentials:handler]; - } andUnauthenticatedHandler:nil]; - // Add a fake matrix session to each room summary to provide it a REST client (used to handle correctly the room avatar). - MXSession *session = [[MXSession alloc] initWithMatrixRestClient:mxRestClient]; - for (id summary in summaries) { if (!summary.hiddenFromUser && summary.roomType == MXRoomTypeRoom) { if ([summary respondsToSelector:@selector(setMatrixSession:)]) { - [summary setMatrixSession:session]; + [summary setMatrixSession:self.session]; } MXKRecentCellData *recentCellData = [[MXKRecentCellData alloc] initWithRoomSummary:summary dataSource:nil]; diff --git a/RiotShareExtension/Shared/ShareManager.m b/RiotShareExtension/Shared/ShareManager.m index ffb94fc8c..2233b352d 100644 --- a/RiotShareExtension/Shared/ShareManager.m +++ b/RiotShareExtension/Shared/ShareManager.m @@ -45,6 +45,10 @@ @implementation ShareManager +/// A fake matrix session used to provide summaries with a REST client to handle room avatars. +/// The session is stored statically to prevent new ones from being created for each share. +static MXSession *fakeSession; + - (instancetype)initWithShareItemSender:(id)itemSender type:(ShareManagerType)type { @@ -182,6 +186,7 @@ // We consider the first enabled account. // TODO: Handle multiple accounts self.userAccount = [MXKAccountManager sharedManager].activeAccounts.firstObject; + [self checkFakeSession]; } // Reset the file store to reload the room data. @@ -191,12 +196,12 @@ _fileStore = nil; } - if (self.userAccount) + if (self.userAccount && fakeSession) { _fileStore = [[MXFileStore alloc] initWithCredentials:self.userAccount.mxCredentials]; ShareDataSource *roomDataSource = [[ShareDataSource alloc] initWithFileStore:_fileStore - credentials:self.userAccount.mxCredentials]; + session:fakeSession]; [self.shareViewController configureWithState:ShareViewControllerAccountStateConfigured roomDataSource:roomDataSource]; @@ -206,6 +211,27 @@ } } +- (void)checkFakeSession +{ + if (!self.userAccount) + { + return; + } + + if (fakeSession && [fakeSession.credentials.userId isEqualToString:self.userAccount.mxCredentials.userId]) + { + return; + } + + MXRestClient *mxRestClient = [[MXRestClient alloc] initWithCredentials:self.userAccount.mxCredentials + andOnUnrecognizedCertificateBlock:nil + andPersistentTokenDataHandler:^(void (^handler)(NSArray *credentials, void (^completion)(BOOL didUpdateCredentials))) { + [[MXKAccountManager sharedManager] readAndWriteCredentials:handler]; + } andUnauthenticatedHandler:nil]; + + fakeSession = [[MXSession alloc] initWithMatrixRestClient:mxRestClient]; +} + - (void)didStartSending { [self.shareViewController showProgressIndicator]; From 158b2860ec7973bbcfb5b8bae60fc2f702841d82 Mon Sep 17 00:00:00 2001 From: Doug Date: Fri, 18 Mar 2022 15:12:21 +0000 Subject: [PATCH 23/38] Fix sending messages via the siri intent. Fix a bug sending videos into unencrypted rooms. --- RiotShareExtension/Sources/ShareItemSender.m | 2 +- SiriIntents/IntentHandler.m | 19 +++++++++++++++---- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/RiotShareExtension/Sources/ShareItemSender.m b/RiotShareExtension/Sources/ShareItemSender.m index 69f925a28..2c11e2a46 100644 --- a/RiotShareExtension/Sources/ShareItemSender.m +++ b/RiotShareExtension/Sources/ShareItemSender.m @@ -641,7 +641,7 @@ typedef NS_ENUM(NSInteger, ImageCompressionMode) { if (!RiotSettings.shared.showMediaCompressionPrompt) { - [MXSDKOptions sharedInstance].videoConversionPresetName = AVCaptureSessionPreset1920x1080; + [MXSDKOptions sharedInstance].videoConversionPresetName = AVAssetExportPreset1920x1080; sendVideo(); } else diff --git a/SiriIntents/IntentHandler.m b/SiriIntents/IntentHandler.m index 8220d9fed..94d120a4f 100644 --- a/SiriIntents/IntentHandler.m +++ b/SiriIntents/IntentHandler.m @@ -27,6 +27,12 @@ // Build Settings @property (nonatomic) id configuration; +/** + The room that is currently being used to send a message. This is to ensure a + strong ref is maintained on the `MXRoom` until sending has completed. + */ +@property (nonatomic) MXRoom *selectedRoom; + @end @implementation IntentHandler @@ -242,17 +248,22 @@ [session setStore:fileStore success:^{ MXStrongifyAndReturnIfNil(session); - MXRoom *room = [MXRoom loadRoomFromStore:fileStore withRoomId:roomID matrixSession:session]; + self.selectedRoom = [MXRoom loadRoomFromStore:fileStore withRoomId:roomID matrixSession:session]; // Do not warn for unknown devices. We have cross-signing now session.crypto.warnOnUnknowDevices = NO; - [room sendTextMessage:intent.content - threadId:nil - success:^(NSString *eventId) { + MXWeakify(self); + [self.selectedRoom sendTextMessage:intent.content + threadId:nil + success:^(NSString *eventId) { completeWithCode(INSendMessageIntentResponseCodeSuccess); + MXStrongifyAndReturnIfNil(self); + self.selectedRoom = nil; } failure:^(NSError *error) { completeWithCode(INSendMessageIntentResponseCodeFailure); + MXStrongifyAndReturnIfNil(self); + self.selectedRoom = nil; }]; } failure:^(NSError *error) { From f91a56295fc5642d92501022da14e8772fb07f92 Mon Sep 17 00:00:00 2001 From: Doug Date: Fri, 18 Mar 2022 15:19:57 +0000 Subject: [PATCH 24/38] Re-enable NSException on Share extension dismiss. Disable crash logging in the share extension because of this. --- Riot/Categories/Bundle.swift | 5 +++++ Riot/Modules/Analytics/Analytics.swift | 9 +++++++-- .../Sources/ShareExtensionRootViewController.m | 4 ++++ changelog.d/5805.bugfix | 1 + 4 files changed, 17 insertions(+), 2 deletions(-) create mode 100644 changelog.d/5805.bugfix diff --git a/Riot/Categories/Bundle.swift b/Riot/Categories/Bundle.swift index 055d9c64c..5b3430154 100644 --- a/Riot/Categories/Bundle.swift +++ b/Riot/Categories/Bundle.swift @@ -30,4 +30,9 @@ public extension Bundle { } return bundle } + + /// Whether or not the bundle is the RiotShareExtension. + var isShareExtension: Bool { + bundleURL.lastPathComponent.contains("RiotShareExtension.appex") + } } diff --git a/Riot/Modules/Analytics/Analytics.swift b/Riot/Modules/Analytics/Analytics.swift index 1333864ab..74880e69f 100644 --- a/Riot/Modules/Analytics/Analytics.swift +++ b/Riot/Modules/Analytics/Analytics.swift @@ -121,8 +121,13 @@ import AnalyticsEvents MXLog.debug("[Analytics] Started.") - // Catch and log crashes - MXLogger.logCrashes(true) + if Bundle.main.isShareExtension { + // Don't log crashes in the share extension + } else { + // Catch and log crashes + MXLogger.logCrashes(true) + } + MXLogger.setBuildVersion(AppInfo.current.buildInfo.readableBuildVersion) } diff --git a/RiotShareExtension/Sources/ShareExtensionRootViewController.m b/RiotShareExtension/Sources/ShareExtensionRootViewController.m index 5c6421551..c43eef81e 100644 --- a/RiotShareExtension/Sources/ShareExtensionRootViewController.m +++ b/RiotShareExtension/Sources/ShareExtensionRootViewController.m @@ -99,6 +99,10 @@ { [self dismissViewControllerAnimated:true completion:^{ [self.presentingViewController dismissViewControllerAnimated:false completion:nil]; + + // FIXME: Share extension memory usage increase when launched several times and then crash due to some memory leaks. + // For now, we force the share extension to exit and free memory. + [NSException raise:@"Kill the app extension" format:@"Free memory used by share extension"]; }]; } diff --git a/changelog.d/5805.bugfix b/changelog.d/5805.bugfix new file mode 100644 index 000000000..831afcca9 --- /dev/null +++ b/changelog.d/5805.bugfix @@ -0,0 +1 @@ +Share Extension: Stop logging crashes due to intentional exception that frees up memory and handle changes to MXRoom in the SDK. From c1a18bdf3f872bf3069866391a0af00977673419 Mon Sep 17 00:00:00 2001 From: Gil Eluard Date: Mon, 21 Mar 2022 15:33:02 +0100 Subject: [PATCH 25/38] Update gemfile.lock to match versions with podfile.lock --- Gemfile.lock | 70 +++++++++++++++++++++++++++------------------------- 1 file changed, 37 insertions(+), 33 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 610cbbadd..edfd3f855 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -3,7 +3,7 @@ GEM specs: CFPropertyList (3.0.5) rexml - activesupport (6.1.4.4) + activesupport (6.1.5) concurrent-ruby (~> 1.0, >= 1.0.2) i18n (>= 1.6, < 2) minitest (>= 5.1) @@ -17,27 +17,27 @@ GEM artifactory (3.0.15) atomos (0.1.3) aws-eventstream (1.2.0) - aws-partitions (1.541.0) - aws-sdk-core (3.124.0) + aws-partitions (1.568.0) + aws-sdk-core (3.130.0) aws-eventstream (~> 1, >= 1.0.2) aws-partitions (~> 1, >= 1.525.0) aws-sigv4 (~> 1.1) jmespath (~> 1.0) - aws-sdk-kms (1.52.0) - aws-sdk-core (~> 3, >= 3.122.0) + aws-sdk-kms (1.55.0) + aws-sdk-core (~> 3, >= 3.127.0) aws-sigv4 (~> 1.1) - aws-sdk-s3 (1.109.0) - aws-sdk-core (~> 3, >= 3.122.0) + aws-sdk-s3 (1.113.0) + aws-sdk-core (~> 3, >= 3.127.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.4) aws-sigv4 (1.4.0) aws-eventstream (~> 1, >= 1.0.2) babosa (1.0.4) claide (1.0.3) - cocoapods (1.11.2) + cocoapods (1.11.3) addressable (~> 2.8) claide (>= 1.0.2, < 2.0) - cocoapods-core (= 1.11.2) + cocoapods-core (= 1.11.3) cocoapods-deintegrate (>= 1.0.3, < 2.0) cocoapods-downloader (>= 1.4.0, < 2.0) cocoapods-plugins (>= 1.0.0, < 2.0) @@ -52,7 +52,7 @@ GEM nap (~> 1.0) ruby-macho (>= 1.0, < 3.0) xcodeproj (>= 1.21.0, < 2.0) - cocoapods-core (1.11.2) + cocoapods-core (1.11.3) activesupport (>= 5.0, < 7) addressable (~> 2.8) algoliasearch (~> 1.0) @@ -86,17 +86,18 @@ GEM escape (0.0.4) ethon (0.15.0) ffi (>= 1.15.0) - excon (0.89.0) - faraday (1.8.0) + excon (0.92.1) + faraday (1.10.0) faraday-em_http (~> 1.0) faraday-em_synchrony (~> 1.0) faraday-excon (~> 1.1) - faraday-httpclient (~> 1.0.1) + faraday-httpclient (~> 1.0) + faraday-multipart (~> 1.0) faraday-net_http (~> 1.0) - faraday-net_http_persistent (~> 1.1) + faraday-net_http_persistent (~> 1.0) faraday-patron (~> 1.0) faraday-rack (~> 1.0) - multipart-post (>= 1.2, < 3) + faraday-retry (~> 1.0) ruby2_keywords (>= 0.0.4) faraday-cookie_jar (0.0.7) faraday (>= 0.8.0) @@ -105,14 +106,17 @@ GEM faraday-em_synchrony (1.0.0) faraday-excon (1.1.0) faraday-httpclient (1.0.1) + faraday-multipart (1.0.3) + multipart-post (>= 1.2, < 3) faraday-net_http (1.0.1) faraday-net_http_persistent (1.2.0) faraday-patron (1.0.0) faraday-rack (1.0.0) + faraday-retry (1.0.3) faraday_middleware (1.2.0) faraday (~> 1.0) fastimage (2.2.6) - fastlane (2.199.0) + fastlane (2.205.0) CFPropertyList (>= 2.3, < 4.0.0) addressable (>= 2.8, < 3.0.0) artifactory (~> 3.0) @@ -157,13 +161,13 @@ GEM fastlane-plugin-versioning (0.5.0) fastlane-plugin-xcodegen (1.1.0) fastlane-plugin-brew (~> 0.1.1) - ffi (1.15.4) + ffi (1.15.5) fourflusher (2.3.1) fuzzy_match (2.0.4) gh_inspector (1.1.3) - google-apis-androidpublisher_v3 (0.14.0) + google-apis-androidpublisher_v3 (0.16.0) google-apis-core (>= 0.4, < 2.a) - google-apis-core (0.4.1) + google-apis-core (0.4.2) addressable (~> 2.5, >= 2.5.1) googleauth (>= 0.16.2, < 2.a) httpclient (>= 2.8.1, < 3.a) @@ -172,11 +176,11 @@ GEM retriable (>= 2.0, < 4.a) rexml webrick - google-apis-iamcredentials_v1 (0.9.0) + google-apis-iamcredentials_v1 (0.10.0) google-apis-core (>= 0.4, < 2.a) - google-apis-playcustomapp_v1 (0.6.0) + google-apis-playcustomapp_v1 (0.7.0) google-apis-core (>= 0.4, < 2.a) - google-apis-storage_v1 (0.10.0) + google-apis-storage_v1 (0.11.0) google-apis-core (>= 0.4, < 2.a) google-cloud-core (1.6.0) google-cloud-env (~> 1.0) @@ -184,7 +188,7 @@ GEM google-cloud-env (1.5.0) faraday (>= 0.17.3, < 2.0) google-cloud-errors (1.2.0) - google-cloud-storage (1.35.0) + google-cloud-storage (1.36.1) addressable (~> 2.8) digest-crc (~> 0.4) google-apis-iamcredentials_v1 (~> 0.1) @@ -192,8 +196,8 @@ GEM google-cloud-core (~> 1.6) googleauth (>= 0.16.2, < 2.a) mini_mime (~> 1.0) - googleauth (1.1.0) - faraday (>= 0.17.3, < 2.0) + googleauth (1.1.2) + faraday (>= 0.17.3, < 3.a) jwt (>= 1.4, < 3.0) memoist (~> 0.16) multi_json (~> 1.11) @@ -204,15 +208,15 @@ GEM http-cookie (1.0.4) domain_name (~> 0.5) httpclient (2.8.3) - i18n (1.8.11) + i18n (1.10.0) concurrent-ruby (~> 1.0) - jmespath (1.4.0) + jmespath (1.6.1) json (2.6.1) jwt (2.3.0) memoist (0.16.2) mime-types (3.4.1) mime-types-data (~> 3.2015) - mime-types-data (3.2021.1115) + mime-types-data (3.2022.0105) mini_magick (4.11.0) mini_mime (1.1.2) minitest (5.15.0) @@ -244,9 +248,9 @@ GEM ruby2_keywords (0.0.5) rubyzip (2.3.2) security (0.1.3) - signet (0.16.0) + signet (0.16.1) addressable (~> 2.8) - faraday (>= 0.17.3, < 2.0) + faraday (>= 0.17.5, < 3.0) jwt (>= 1.5, < 3.0) multi_json (~> 1.10) simctl (1.6.8) @@ -267,7 +271,7 @@ GEM uber (0.1.0) unf (0.1.4) unf_ext - unf_ext (0.0.8) + unf_ext (0.0.8.1) unicode-display_width (1.8.0) webrick (1.7.0) word_wrap (1.0.0) @@ -285,7 +289,7 @@ GEM rouge (~> 2.0.7) xcpretty-travis-formatter (1.0.1) xcpretty (~> 0.2, >= 0.0.7) - zeitwerk (2.5.1) + zeitwerk (2.5.4) PLATFORMS ruby @@ -299,4 +303,4 @@ DEPENDENCIES xcode-install BUNDLED WITH - 2.2.32 + 2.3.9 From 630feb757f333b89e297dd8286d7b4b5b3f08614 Mon Sep 17 00:00:00 2001 From: Andy Uhnak Date: Mon, 21 Mar 2022 16:08:17 +0000 Subject: [PATCH 26/38] Do not clear cache if there are no stored filters --- Riot/Modules/MatrixKit/Models/Account/MXKAccount.m | 5 +++++ changelog.d/5873.bugfix | 1 + 2 files changed, 6 insertions(+) create mode 100644 changelog.d/5873.bugfix diff --git a/Riot/Modules/MatrixKit/Models/Account/MXKAccount.m b/Riot/Modules/MatrixKit/Models/Account/MXKAccount.m index d0047f5f0..728a90fa1 100644 --- a/Riot/Modules/MatrixKit/Models/Account/MXKAccount.m +++ b/Riot/Modules/MatrixKit/Models/Account/MXKAccount.m @@ -2073,6 +2073,11 @@ static NSArray *initialSyncSilentErrorsHTTPStatusCodes; mxSession.syncFilterId, syncFilter.JSONDictionary); completion(NO); } + else if (!mxSession.store.allFilterIds.count) + { + MXLogDebug(@"[MXKAccount] There are no filters stored in this session, proceed as if no /sync was done before"); + completion(YES); + } else { // Check the filter is the one previously set diff --git a/changelog.d/5873.bugfix b/changelog.d/5873.bugfix new file mode 100644 index 000000000..cf3c09280 --- /dev/null +++ b/changelog.d/5873.bugfix @@ -0,0 +1 @@ +MXAccount: Do not clear cache if there are no stored filters From bd45e2b93bad9cfbfc728e97c692c8676960305a Mon Sep 17 00:00:00 2001 From: Stefan Ceriu Date: Mon, 21 Mar 2022 11:30:54 +0200 Subject: [PATCH 27/38] Fix user suggestions not showing up when re-entering a room. --- Riot/Modules/Room/RoomViewController.m | 7 ++++--- changelog.d/pr-5876.bugfix | 1 + 2 files changed, 5 insertions(+), 3 deletions(-) create mode 100644 changelog.d/pr-5876.bugfix diff --git a/Riot/Modules/Room/RoomViewController.m b/Riot/Modules/Room/RoomViewController.m index dbfb3cfc9..c63bbb11a 100644 --- a/Riot/Modules/Room/RoomViewController.m +++ b/Riot/Modules/Room/RoomViewController.m @@ -400,6 +400,8 @@ static CGSize kThreadListBarButtonItemImageSize; [self registerURLPreviewNotifications]; [self setupActions]; + + [self setupUserSuggestionViewIfNeeded]; } - (void)userInterfaceThemeDidChange @@ -1045,7 +1047,7 @@ static CGSize kThreadListBarButtonItemImageSize; room:dataSource.room]; _userSuggestionCoordinator.delegate = self; - [self setupUserSuggestionView]; + [self setupUserSuggestionViewIfNeeded]; } - (void)onRoomDataSourceReady @@ -2370,10 +2372,9 @@ static CGSize kThreadListBarButtonItemImageSize; } } -- (void)setupUserSuggestionView +- (void)setupUserSuggestionViewIfNeeded { if(!self.isViewLoaded) { - MXLogError(@"Failed setting up user suggestions. View not loaded."); return; } diff --git a/changelog.d/pr-5876.bugfix b/changelog.d/pr-5876.bugfix new file mode 100644 index 000000000..aca03fdfa --- /dev/null +++ b/changelog.d/pr-5876.bugfix @@ -0,0 +1 @@ +Fix user suggestions not showing up when re-entering a room. \ No newline at end of file From 11b55aea4b02a8ba8cca337fb292c0d8fc67aa63 Mon Sep 17 00:00:00 2001 From: Doug Date: Mon, 7 Mar 2022 16:30:26 +0000 Subject: [PATCH 28/38] Add FTUE display name screen. Use UserSession instead of userId & MXSession in OnboardingCoordinator --- Riot/Assets/en.lproj/Untranslated.strings | 7 ++ Riot/Generated/UntranslatedStrings.swift | 24 ++++ .../Onboarding/OnboardingCoordinator.swift | 65 +++++++--- .../Modules/Common/Mock/MockAppScreens.swift | 1 + ...OnboardingCongratulationsCoordinator.swift | 19 ++- .../OnboardingDisplayNameCoordinator.swift | 64 ++++++++++ ...MockOnboardingDisplayNameScreenState.swift | 64 ++++++++++ .../OnboardingDisplayNameModels.swift | 40 ++++++ .../OnboardingDisplayNameViewModel.swift | 81 ++++++++++++ ...boardingDisplayNameViewModelProtocol.swift | 26 ++++ .../OnboardingDisplayNameService.swift | 52 ++++++++ .../MockOnboardingDisplayNameService.swift | 35 ++++++ ...OnboardingDisplayNameServiceProtocol.swift | 27 ++++ .../UI/OnboardingDisplayNameUITests.swift | 53 ++++++++ .../OnboardingDisplayNameViewModelTests.swift | 57 +++++++++ .../View/OnboardingDisplayNameScreen.swift | 115 ++++++++++++++++++ .../OnboardingSplashScreenCoordinator.swift | 9 +- ...nboardingUseCaseSelectionCoordinator.swift | 11 +- 18 files changed, 717 insertions(+), 33 deletions(-) create mode 100644 RiotSwiftUI/Modules/Onboarding/DisplayName/Coordinator/OnboardingDisplayNameCoordinator.swift create mode 100644 RiotSwiftUI/Modules/Onboarding/DisplayName/MockOnboardingDisplayNameScreenState.swift create mode 100644 RiotSwiftUI/Modules/Onboarding/DisplayName/OnboardingDisplayNameModels.swift create mode 100644 RiotSwiftUI/Modules/Onboarding/DisplayName/OnboardingDisplayNameViewModel.swift create mode 100644 RiotSwiftUI/Modules/Onboarding/DisplayName/OnboardingDisplayNameViewModelProtocol.swift create mode 100644 RiotSwiftUI/Modules/Onboarding/DisplayName/Service/MatrixSDK/OnboardingDisplayNameService.swift create mode 100644 RiotSwiftUI/Modules/Onboarding/DisplayName/Service/Mock/MockOnboardingDisplayNameService.swift create mode 100644 RiotSwiftUI/Modules/Onboarding/DisplayName/Service/OnboardingDisplayNameServiceProtocol.swift create mode 100644 RiotSwiftUI/Modules/Onboarding/DisplayName/Test/UI/OnboardingDisplayNameUITests.swift create mode 100644 RiotSwiftUI/Modules/Onboarding/DisplayName/Test/Unit/OnboardingDisplayNameViewModelTests.swift create mode 100644 RiotSwiftUI/Modules/Onboarding/DisplayName/View/OnboardingDisplayNameScreen.swift diff --git a/Riot/Assets/en.lproj/Untranslated.strings b/Riot/Assets/en.lproj/Untranslated.strings index d55b74231..b1ec8050e 100644 --- a/Riot/Assets/en.lproj/Untranslated.strings +++ b/Riot/Assets/en.lproj/Untranslated.strings @@ -21,3 +21,10 @@ "onboarding_congratulations_message" = "Your account\n%@\nhas been created."; "onboarding_congratulations_personalise_button" = "Personalise profile"; "onboarding_congratulations_home_button" = "Take me home"; + +"onboarding_display_name_title" = "Choose a display name"; +"onboarding_display_name_message" = "This will be shown when you send messages."; +"onboarding_display_name_placeholder" = "Display Name"; +"onboarding_display_name_hint" = "You can change this later"; +"onboarding_display_name_save" = "Save and continue"; +"onboarding_display_name_skip" = "Skip this step"; diff --git a/Riot/Generated/UntranslatedStrings.swift b/Riot/Generated/UntranslatedStrings.swift index efc032506..d98fd9680 100644 --- a/Riot/Generated/UntranslatedStrings.swift +++ b/Riot/Generated/UntranslatedStrings.swift @@ -26,6 +26,30 @@ public extension VectorL10n { static var onboardingCongratulationsTitle: String { return VectorL10n.tr("Untranslated", "onboarding_congratulations_title") } + /// You can change this later + static var onboardingDisplayNameHint: String { + return VectorL10n.tr("Untranslated", "onboarding_display_name_hint") + } + /// This will be shown when you send messages. + static var onboardingDisplayNameMessage: String { + return VectorL10n.tr("Untranslated", "onboarding_display_name_message") + } + /// Display Name + static var onboardingDisplayNamePlaceholder: String { + return VectorL10n.tr("Untranslated", "onboarding_display_name_placeholder") + } + /// Save and continue + static var onboardingDisplayNameSave: String { + return VectorL10n.tr("Untranslated", "onboarding_display_name_save") + } + /// Skip this step + static var onboardingDisplayNameSkip: String { + return VectorL10n.tr("Untranslated", "onboarding_display_name_skip") + } + /// Choose a display name + static var onboardingDisplayNameTitle: String { + return VectorL10n.tr("Untranslated", "onboarding_display_name_title") + } } // swiftlint:enable function_parameter_count identifier_name line_length type_body_length diff --git a/Riot/Modules/Onboarding/OnboardingCoordinator.swift b/Riot/Modules/Onboarding/OnboardingCoordinator.swift index dd2a379ba..63d0e47cc 100644 --- a/Riot/Modules/Onboarding/OnboardingCoordinator.swift +++ b/Riot/Modules/Onboarding/OnboardingCoordinator.swift @@ -182,6 +182,7 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { } /// Displays the next view in the flow after the use case screen. + @available(iOS 14.0, *) private func useCaseSelectionCoordinator(_ coordinator: OnboardingUseCaseSelectionCoordinator, didCompleteWith result: OnboardingUseCaseViewModelResult) { useCaseResult = result showAuthenticationScreen() @@ -251,8 +252,11 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { // Check whether another screen should be shown. if #available(iOS 14.0, *) { - if authenticationType == .register, let userId = session.credentials.userId, BuildSettings.onboardingShowAccountPersonalisation { - showCongratulationsScreen(userId: userId) + if authenticationType == .register, + let userId = session.credentials.userId, + let userSession = UserSessionsService.shared.userSession(withUserId: userId), + BuildSettings.onboardingShowAccountPersonalisation { + showCongratulationsScreen(userSession: userSession) return } else if Analytics.shared.shouldShowAnalyticsPrompt { showAnalyticsPrompt(for: session) @@ -288,10 +292,10 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { // MARK: - Post-Authentication @available(iOS 14.0, *) - private func showCongratulationsScreen(userId: String) { + private func showCongratulationsScreen(userSession: UserSession) { MXLog.debug("[OnboardingCoordinator] showCongratulationsScreen") - let parameters = OnboardingCongratulationsCoordinatorParameters(userId: userId) + let parameters = OnboardingCongratulationsCoordinatorParameters(userSession: userSession) let coordinator = OnboardingCongratulationsCoordinator(parameters: parameters) coordinator.completion = { [weak self, weak coordinator] result in @@ -309,22 +313,47 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { } @available(iOS 14.0, *) - private func congratulationsCoordinator(_ coordinator: OnboardingCongratulationsCoordinator, didCompleteWith result: OnboardingCongratulationsViewModelResult) { - if let session = session { - switch result { - case .personaliseProfile: - // TODO: Profile screens here instead. - if Analytics.shared.shouldShowAnalyticsPrompt { - showAnalyticsPrompt(for: session) - return - } - case .takeMeHome: - if Analytics.shared.shouldShowAnalyticsPrompt { - showAnalyticsPrompt(for: session) - return - } + private func congratulationsCoordinator(_ coordinator: OnboardingCongratulationsCoordinator, didCompleteWith result: OnboardingCongratulationsCoordinatorResult) { + switch result { + case .personaliseProfile(let userSession): + #warning("Check server capabilities first") + showDisplayNameScreen(for: userSession) + return + case .takeMeHome(let userSession): + if Analytics.shared.shouldShowAnalyticsPrompt { + showAnalyticsPrompt(for: userSession.matrixSession) + return } } + } + + @available(iOS 14.0, *) + private func showDisplayNameScreen(for userSession: UserSession) { + MXLog.debug("[OnboardingCoordinator]: showDisplayNameScreen") + + let parameters = OnboardingDisplayNameCoordinatorParameters(userSession: userSession) + let coordinator = OnboardingDisplayNameCoordinator(parameters: parameters) + + coordinator.completion = { [weak self, weak coordinator] session in + guard let self = self, let coordinator = coordinator else { return } + self.displayNameCoordinator(coordinator, didCompleteWith: session) + } + + add(childCoordinator: coordinator) + coordinator.start() + + navigationRouter.setRootModule(coordinator, hideNavigationBar: false, animated: true) { [weak self] in + self?.remove(childCoordinator: coordinator) + } + } + + @available(iOS 14.0, *) + private func displayNameCoordinator(_ coordinator: OnboardingDisplayNameCoordinator, didCompleteWith userSession: UserSession) { + // TODO: Show Avatar screen. + if Analytics.shared.shouldShowAnalyticsPrompt { + showAnalyticsPrompt(for: userSession.matrixSession) + return + } onboardingFinished = true completeIfReady() diff --git a/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift b/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift index 3c5909a4c..709a06325 100644 --- a/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift +++ b/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift @@ -20,6 +20,7 @@ import Foundation @available(iOS 14.0, *) enum MockAppScreens { static let appScreens: [MockScreenState.Type] = [ + MockOnboardingDisplayNameScreenState.self, MockOnboardingCongratulationsScreenState.self, MockOnboardingUseCaseSelectionScreenState.self, MockOnboardingSplashScreenScreenState.self, diff --git a/RiotSwiftUI/Modules/Onboarding/Congratulations/Coordinator/OnboardingCongratulationsCoordinator.swift b/RiotSwiftUI/Modules/Onboarding/Congratulations/Coordinator/OnboardingCongratulationsCoordinator.swift index 6bed87cc2..c487829a4 100644 --- a/RiotSwiftUI/Modules/Onboarding/Congratulations/Coordinator/OnboardingCongratulationsCoordinator.swift +++ b/RiotSwiftUI/Modules/Onboarding/Congratulations/Coordinator/OnboardingCongratulationsCoordinator.swift @@ -17,7 +17,12 @@ import SwiftUI struct OnboardingCongratulationsCoordinatorParameters { - let userId: String + let userSession: UserSession +} + +enum OnboardingCongratulationsCoordinatorResult { + case personaliseProfile(UserSession) + case takeMeHome(UserSession) } final class OnboardingCongratulationsCoordinator: Coordinator, Presentable { @@ -34,7 +39,7 @@ final class OnboardingCongratulationsCoordinator: Coordinator, Presentable { // Must be used only internally var childCoordinators: [Coordinator] = [] - var completion: ((OnboardingCongratulationsViewModelResult) -> Void)? + var completion: ((OnboardingCongratulationsCoordinatorResult) -> Void)? // MARK: - Setup @@ -42,7 +47,7 @@ final class OnboardingCongratulationsCoordinator: Coordinator, Presentable { init(parameters: OnboardingCongratulationsCoordinatorParameters) { self.parameters = parameters - let viewModel = OnboardingCongratulationsViewModel(userId: parameters.userId) + let viewModel = OnboardingCongratulationsViewModel(userId: parameters.userSession.userId) let view = OnboardingCongratulationsScreen(viewModel: viewModel.context) onboardingCongratulationsViewModel = viewModel onboardingCongratulationsHostingController = VectorHostingController(rootView: view) @@ -54,7 +59,13 @@ final class OnboardingCongratulationsCoordinator: Coordinator, Presentable { onboardingCongratulationsViewModel.completion = { [weak self] result in guard let self = self else { return } MXLog.debug("[OnboardingCongratulationsCoordinator] OnboardingCongratulationsViewModel did complete with result: \(result).") - self.completion?(result) + + switch result { + case .personaliseProfile: + self.completion?(.personaliseProfile(self.parameters.userSession)) + case .takeMeHome: + self.completion?(.takeMeHome(self.parameters.userSession)) + } } } diff --git a/RiotSwiftUI/Modules/Onboarding/DisplayName/Coordinator/OnboardingDisplayNameCoordinator.swift b/RiotSwiftUI/Modules/Onboarding/DisplayName/Coordinator/OnboardingDisplayNameCoordinator.swift new file mode 100644 index 000000000..e604ffc16 --- /dev/null +++ b/RiotSwiftUI/Modules/Onboarding/DisplayName/Coordinator/OnboardingDisplayNameCoordinator.swift @@ -0,0 +1,64 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 OnboardingDisplayNameCoordinatorParameters { + let userSession: UserSession +} + +@available(iOS 14.0, *) +final class OnboardingDisplayNameCoordinator: Coordinator, Presentable { + + // MARK: - Properties + + // MARK: Private + + private let parameters: OnboardingDisplayNameCoordinatorParameters + private let onboardingDisplayNameHostingController: VectorHostingController + private var onboardingDisplayNameViewModel: OnboardingDisplayNameViewModelProtocol + + // MARK: Public + + // Must be used only internally + var childCoordinators: [Coordinator] = [] + var completion: ((UserSession) -> Void)? + + // MARK: - Setup + + init(parameters: OnboardingDisplayNameCoordinatorParameters) { + self.parameters = parameters + let viewModel = OnboardingDisplayNameViewModel.makeOnboardingDisplayNameViewModel(onboardingDisplayNameService: OnboardingDisplayNameService(userSession: parameters.userSession)) + let view = OnboardingDisplayNameScreen(viewModel: viewModel.context) + onboardingDisplayNameViewModel = viewModel + onboardingDisplayNameHostingController = VectorHostingController(rootView: view) + onboardingDisplayNameHostingController.enableNavigationBarScrollEdgesAppearance = true + } + + // MARK: - Public + func start() { + MXLog.debug("[OnboardingDisplayNameCoordinator] did start.") + onboardingDisplayNameViewModel.completion = { [weak self] in + guard let self = self else { return } + MXLog.debug("[OnboardingDisplayNameCoordinator] OnboardingDisplayNameViewModel did complete.") + self.completion?(self.parameters.userSession) + } + } + + func toPresentable() -> UIViewController { + return self.onboardingDisplayNameHostingController + } +} diff --git a/RiotSwiftUI/Modules/Onboarding/DisplayName/MockOnboardingDisplayNameScreenState.swift b/RiotSwiftUI/Modules/Onboarding/DisplayName/MockOnboardingDisplayNameScreenState.swift new file mode 100644 index 000000000..e74fb1d01 --- /dev/null +++ b/RiotSwiftUI/Modules/Onboarding/DisplayName/MockOnboardingDisplayNameScreenState.swift @@ -0,0 +1,64 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 MockOnboardingDisplayNameScreenState: MockScreenState, CaseIterable { + // A case for each state you want to represent + // with specific, minimal associated data that will allow you + // mock that screen. + case emptyTextField + case filledTextField(displayName: String) + case operationInProgress(displayName: String) + + /// The associated screen + var screenType: Any.Type { + OnboardingDisplayNameScreen.self + } + + /// A list of screen state definitions + static var allCases: [MockOnboardingDisplayNameScreenState] { + [ + MockOnboardingDisplayNameScreenState.emptyTextField, + MockOnboardingDisplayNameScreenState.filledTextField(displayName: "Test User"), + MockOnboardingDisplayNameScreenState.operationInProgress(displayName: "Test User"), + ] + } + + /// Generate the view struct for the screen state. + var screenView: ([Any], AnyView) { + let service: MockOnboardingDisplayNameService + switch self { + case .emptyTextField: + service = MockOnboardingDisplayNameService() + case .filledTextField(let displayName): + service = MockOnboardingDisplayNameService(displayName: displayName) + case .operationInProgress(let displayName): + service = MockOnboardingDisplayNameService(displayName: displayName, isWaiting: true) + } + let viewModel = OnboardingDisplayNameViewModel.makeOnboardingDisplayNameViewModel(onboardingDisplayNameService: service) + + // can simulate service and viewModel actions here if needs be. + + return ( + [service, viewModel], AnyView(OnboardingDisplayNameScreen(viewModel: viewModel.context)) + ) + } +} diff --git a/RiotSwiftUI/Modules/Onboarding/DisplayName/OnboardingDisplayNameModels.swift b/RiotSwiftUI/Modules/Onboarding/DisplayName/OnboardingDisplayNameModels.swift new file mode 100644 index 000000000..efc0ce011 --- /dev/null +++ b/RiotSwiftUI/Modules/Onboarding/DisplayName/OnboardingDisplayNameModels.swift @@ -0,0 +1,40 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +// MARK: View model + +enum OnboardingDisplayNameViewModelResult { + // Can probably be removed +} + +// MARK: View + +struct OnboardingDisplayNameViewState: BindableState { + var isWaiting = false + var bindings: OnboardingDisplayNameBindings +} + +struct OnboardingDisplayNameBindings { + var displayName: String + var alertInfo: AlertInfo? +} + +enum OnboardingDisplayNameViewAction { + case save + case skip +} diff --git a/RiotSwiftUI/Modules/Onboarding/DisplayName/OnboardingDisplayNameViewModel.swift b/RiotSwiftUI/Modules/Onboarding/DisplayName/OnboardingDisplayNameViewModel.swift new file mode 100644 index 000000000..84ca8c431 --- /dev/null +++ b/RiotSwiftUI/Modules/Onboarding/DisplayName/OnboardingDisplayNameViewModel.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 +import Combine + +@available(iOS 14, *) +typealias OnboardingDisplayNameViewModelType = StateStoreViewModel +@available(iOS 14, *) +class OnboardingDisplayNameViewModel: OnboardingDisplayNameViewModelType, OnboardingDisplayNameViewModelProtocol { + + // MARK: - Properties + + // MARK: Private + + private let onboardingDisplayNameService: OnboardingDisplayNameServiceProtocol + + // MARK: Public + + var completion: (() -> Void)? + + // MARK: - Setup + + static func makeOnboardingDisplayNameViewModel(onboardingDisplayNameService: OnboardingDisplayNameServiceProtocol) -> OnboardingDisplayNameViewModelProtocol { + return OnboardingDisplayNameViewModel(onboardingDisplayNameService: onboardingDisplayNameService) + } + + private init(onboardingDisplayNameService: OnboardingDisplayNameServiceProtocol) { + self.onboardingDisplayNameService = onboardingDisplayNameService + super.init(initialViewState: Self.defaultState(onboardingDisplayNameService: onboardingDisplayNameService)) + } + + private static func defaultState(onboardingDisplayNameService: OnboardingDisplayNameServiceProtocol) -> OnboardingDisplayNameViewState { + // Start with a blank display name to encourage the user not to just use the first part of their MXID. + return OnboardingDisplayNameViewState(bindings: OnboardingDisplayNameBindings(displayName: "")) + } + + // MARK: - Public + + override func process(viewAction: OnboardingDisplayNameViewAction) { + switch viewAction { + case .save: + setDisplayName() + case .skip: + completion?() + } + } + + // MARK: - Private + + private func setDisplayName() { + state.isWaiting = true + + onboardingDisplayNameService.setDisplayName(context.displayName) { [weak self] result in + guard let self = self else { return } + self.state.isWaiting = false + + switch result { + case .success(_): + self.completion?() + case .failure(let error): + self.state.bindings.alertInfo = AlertInfo(error: error as NSError) + } + } + } +} diff --git a/RiotSwiftUI/Modules/Onboarding/DisplayName/OnboardingDisplayNameViewModelProtocol.swift b/RiotSwiftUI/Modules/Onboarding/DisplayName/OnboardingDisplayNameViewModelProtocol.swift new file mode 100644 index 000000000..51a0ba241 --- /dev/null +++ b/RiotSwiftUI/Modules/Onboarding/DisplayName/OnboardingDisplayNameViewModelProtocol.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 + +protocol OnboardingDisplayNameViewModelProtocol { + + var completion: (() -> Void)? { get set } + @available(iOS 14, *) + static func makeOnboardingDisplayNameViewModel(onboardingDisplayNameService: OnboardingDisplayNameServiceProtocol) -> OnboardingDisplayNameViewModelProtocol + @available(iOS 14, *) + var context: OnboardingDisplayNameViewModelType.Context { get } +} diff --git a/RiotSwiftUI/Modules/Onboarding/DisplayName/Service/MatrixSDK/OnboardingDisplayNameService.swift b/RiotSwiftUI/Modules/Onboarding/DisplayName/Service/MatrixSDK/OnboardingDisplayNameService.swift new file mode 100644 index 000000000..b94ffeda8 --- /dev/null +++ b/RiotSwiftUI/Modules/Onboarding/DisplayName/Service/MatrixSDK/OnboardingDisplayNameService.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 Foundation +import Combine + +@available(iOS 14.0, *) +class OnboardingDisplayNameService: OnboardingDisplayNameServiceProtocol { + + enum ServiceError: Error { + case unknown + } + + // MARK: - Properties + + // MARK: Private + + private let userSession: UserSession + + // MARK: Public + + var displayName: String? { + userSession.account.userDisplayName + } + + // MARK: - Setup + + init(userSession: UserSession) { + self.userSession = userSession + } + + func setDisplayName(_ displayName: String, completion: @escaping (Result) -> Void) { + userSession.account.setUserDisplayName(displayName) { + completion(.success(true)) + } failure: { error in + completion(.failure(error ?? ServiceError.unknown)) + } + } +} diff --git a/RiotSwiftUI/Modules/Onboarding/DisplayName/Service/Mock/MockOnboardingDisplayNameService.swift b/RiotSwiftUI/Modules/Onboarding/DisplayName/Service/Mock/MockOnboardingDisplayNameService.swift new file mode 100644 index 000000000..335f937a8 --- /dev/null +++ b/RiotSwiftUI/Modules/Onboarding/DisplayName/Service/Mock/MockOnboardingDisplayNameService.swift @@ -0,0 +1,35 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import Combine + +@available(iOS 14.0, *) +class MockOnboardingDisplayNameService: OnboardingDisplayNameServiceProtocol { + var displayName: String? + + #warning("isWaiting isn't handled.") + init(displayName: String? = nil, isWaiting: Bool = false) { + self.displayName = displayName + } + + func setDisplayName(_ displayName: String, completion: @escaping (Result) -> Void) { + DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(2)) { + self.displayName = displayName + completion(.success(true)) + } + } +} diff --git a/RiotSwiftUI/Modules/Onboarding/DisplayName/Service/OnboardingDisplayNameServiceProtocol.swift b/RiotSwiftUI/Modules/Onboarding/DisplayName/Service/OnboardingDisplayNameServiceProtocol.swift new file mode 100644 index 000000000..7b74fddab --- /dev/null +++ b/RiotSwiftUI/Modules/Onboarding/DisplayName/Service/OnboardingDisplayNameServiceProtocol.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 +import Combine + +@available(iOS 14.0, *) +protocol OnboardingDisplayNameServiceProtocol { + /// The user's current display name read from the `UserSession`. + var displayName: String? { get } + + /// Update the user's display name. + func setDisplayName(_ displayName: String, completion: @escaping (Result) -> Void) +} diff --git a/RiotSwiftUI/Modules/Onboarding/DisplayName/Test/UI/OnboardingDisplayNameUITests.swift b/RiotSwiftUI/Modules/Onboarding/DisplayName/Test/UI/OnboardingDisplayNameUITests.swift new file mode 100644 index 000000000..b8062c228 --- /dev/null +++ b/RiotSwiftUI/Modules/Onboarding/DisplayName/Test/UI/OnboardingDisplayNameUITests.swift @@ -0,0 +1,53 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 OnboardingDisplayNameUITests: MockScreenTest { + + override class var screenType: MockScreenState.Type { + return MockOnboardingDisplayNameScreenState.self + } + + override class func createTest() -> MockScreenTest { + return OnboardingDisplayNameUITests(selector: #selector(verifyOnboardingDisplayNameScreen)) + } + + func verifyOnboardingDisplayNameScreen() throws { + guard let screenState = screenState as? MockOnboardingDisplayNameScreenState else { fatalError("no screen") } + switch screenState { + case .presence(let presence): + verifyOnboardingDisplayNamePresence(presence: presence) + case .longDisplayName(let name): + verifyOnboardingDisplayNameLongName(name: name) + } + } + + func verifyOnboardingDisplayNamePresence(presence: OnboardingDisplayNamePresence) { + let presenceText = app.staticTexts["presenceText"] + XCTAssert(presenceText.exists) + XCTAssertEqual(presenceText.label, presence.title) + } + + func verifyOnboardingDisplayNameLongName(name: String) { + let displayNameText = app.staticTexts["displayNameText"] + XCTAssert(displayNameText.exists) + XCTAssertEqual(displayNameText.label, name) + } + +} diff --git a/RiotSwiftUI/Modules/Onboarding/DisplayName/Test/Unit/OnboardingDisplayNameViewModelTests.swift b/RiotSwiftUI/Modules/Onboarding/DisplayName/Test/Unit/OnboardingDisplayNameViewModelTests.swift new file mode 100644 index 000000000..d419ecea4 --- /dev/null +++ b/RiotSwiftUI/Modules/Onboarding/DisplayName/Test/Unit/OnboardingDisplayNameViewModelTests.swift @@ -0,0 +1,57 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 OnboardingDisplayNameViewModelTests: XCTestCase { + private enum Constants { + static let presenceInitialValue: OnboardingDisplayNamePresence = .offline + static let displayName = "Alice" + } + var service: MockOnboardingDisplayNameService! + var viewModel: OnboardingDisplayNameViewModelProtocol! + var context: OnboardingDisplayNameViewModelType.Context! + var cancellables = Set() + override func setUpWithError() throws { + service = MockOnboardingDisplayNameService(displayName: Constants.displayName, presence: Constants.presenceInitialValue) + viewModel = OnboardingDisplayNameViewModel.makeOnboardingDisplayNameViewModel(onboardingDisplayNameService: 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: OnboardingDisplayNamePresence = .online + let newPresenceValue2: OnboardingDisplayNamePresence = .idle + service.simulateUpdate(presence: newPresenceValue1) + service.simulateUpdate(presence: newPresenceValue2) + XCTAssertEqual(try awaitDeferred(), [Constants.presenceInitialValue, newPresenceValue1, newPresenceValue2]) + } +} diff --git a/RiotSwiftUI/Modules/Onboarding/DisplayName/View/OnboardingDisplayNameScreen.swift b/RiotSwiftUI/Modules/Onboarding/DisplayName/View/OnboardingDisplayNameScreen.swift new file mode 100644 index 000000000..21f7a9ad1 --- /dev/null +++ b/RiotSwiftUI/Modules/Onboarding/DisplayName/View/OnboardingDisplayNameScreen.swift @@ -0,0 +1,115 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 OnboardingDisplayNameScreen: View { + + // MARK: - Properties + + // MARK: Private + + @Environment(\.theme) private var theme: ThemeSwiftUI + + @State private var isEditingTextField = false + + // MARK: Public + + @ObservedObject var viewModel: OnboardingDisplayNameViewModel.Context + + var body: some View { + ScrollView { + VStack(spacing: 0) { + header + .padding(.bottom, 32) + + textField + .padding(.horizontal, 2) + .padding(.bottom, 20) + + buttons + } + .padding(.horizontal) + .padding(.top, 8) + .frame(maxHeight: .infinity) + } + .accentColor(theme.colors.accent) + .background(theme.colors.background.ignoresSafeArea()) + .alert(item: $viewModel.alertInfo) { $0.alert } + } + + /// The icon, title and message views. + var header: some View { + VStack(spacing: 8) { + Image(Asset.Images.onboardingCongratulationsIcon.name) + .renderingMode(.template) + .foregroundColor(theme.colors.accent) + .padding(.bottom, 8) + .accessibilityHidden(true) + + Text(VectorL10n.onboardingDisplayNameTitle) + .font(theme.fonts.title2B) + .foregroundColor(theme.colors.primaryContent) + + Text(VectorL10n.onboardingDisplayNameMessage) + .font(theme.fonts.subheadline) + .foregroundColor(theme.colors.secondaryContent) + } + } + + /// The text field used to enter the displayname along with a hint. + var textField: some View { + VStack(spacing: 4) { + TextField(VectorL10n.onboardingDisplayNamePlaceholder, text: $viewModel.displayName) { + isEditingTextField = $0 + } + .textFieldStyle(BorderedInputFieldStyle(theme: _theme, isEditing: isEditingTextField)) + + Text(VectorL10n.onboardingDisplayNameHint) + .font(theme.fonts.caption2) + .foregroundColor(theme.colors.tertiaryContent) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + /// The main action buttons in the form. + var buttons: some View { + VStack(spacing: 8) { + Button(VectorL10n.onboardingDisplayNameSave) { + viewModel.send(viewAction: .save) + } + .buttonStyle(PrimaryActionButtonStyle()) + .disabled(viewModel.displayName.isEmpty || viewModel.viewState.isWaiting) + + #warning("Use font/theme") + Button { viewModel.send(viewAction: .skip) } label: { + Text(VectorL10n.onboardingDisplayNameSkip) + .padding(12) + } + } + } +} + +// MARK: - Previews + +@available(iOS 14.0, *) +struct OnboardingDisplayName_Previews: PreviewProvider { + static let stateRenderer = MockOnboardingDisplayNameScreenState.stateRenderer + static var previews: some View { + stateRenderer.screenGroup(addNavigation: true) + } +} diff --git a/RiotSwiftUI/Modules/Onboarding/SplashScreen/Coordinator/OnboardingSplashScreenCoordinator.swift b/RiotSwiftUI/Modules/Onboarding/SplashScreen/Coordinator/OnboardingSplashScreenCoordinator.swift index d031d5826..fb0293acf 100644 --- a/RiotSwiftUI/Modules/Onboarding/SplashScreen/Coordinator/OnboardingSplashScreenCoordinator.swift +++ b/RiotSwiftUI/Modules/Onboarding/SplashScreen/Coordinator/OnboardingSplashScreenCoordinator.swift @@ -20,13 +20,14 @@ protocol OnboardingSplashScreenCoordinatorProtocol: Coordinator, Presentable { var completion: ((OnboardingSplashScreenViewModelResult) -> Void)? { get set } } +@available(iOS 14.0, *) final class OnboardingSplashScreenCoordinator: OnboardingSplashScreenCoordinatorProtocol { // MARK: - Properties // MARK: Private - private let onboardingSplashScreenHostingController: UIViewController + private let onboardingSplashScreenHostingController: VectorHostingController private var onboardingSplashScreenViewModel: OnboardingSplashScreenViewModelProtocol // MARK: Public @@ -37,14 +38,12 @@ final class OnboardingSplashScreenCoordinator: OnboardingSplashScreenCoordinator // MARK: - Setup - @available(iOS 14.0, *) init() { let viewModel = OnboardingSplashScreenViewModel() let view = OnboardingSplashScreen(viewModel: viewModel.context) onboardingSplashScreenViewModel = viewModel - let hostingController = VectorHostingController(rootView: view) - hostingController.vc_removeBackTitle() - onboardingSplashScreenHostingController = hostingController + onboardingSplashScreenHostingController = VectorHostingController(rootView: view) + onboardingSplashScreenHostingController.vc_removeBackTitle() } // MARK: - Public diff --git a/RiotSwiftUI/Modules/Onboarding/UseCase/Coordinator/OnboardingUseCaseSelectionCoordinator.swift b/RiotSwiftUI/Modules/Onboarding/UseCase/Coordinator/OnboardingUseCaseSelectionCoordinator.swift index e6473eeb4..e900c3845 100644 --- a/RiotSwiftUI/Modules/Onboarding/UseCase/Coordinator/OnboardingUseCaseSelectionCoordinator.swift +++ b/RiotSwiftUI/Modules/Onboarding/UseCase/Coordinator/OnboardingUseCaseSelectionCoordinator.swift @@ -16,13 +16,14 @@ import SwiftUI +@available(iOS 14.0, *) final class OnboardingUseCaseSelectionCoordinator: Coordinator, Presentable { // MARK: - Properties // MARK: Private - private let onboardingUseCaseHostingController: UIViewController + private let onboardingUseCaseHostingController: VectorHostingController private var onboardingUseCaseViewModel: OnboardingUseCaseViewModelProtocol // MARK: Public @@ -33,16 +34,14 @@ final class OnboardingUseCaseSelectionCoordinator: Coordinator, Presentable { // MARK: - Setup - @available(iOS 14.0, *) init() { let viewModel = OnboardingUseCaseViewModel() let view = OnboardingUseCaseSelectionScreen(viewModel: viewModel.context) onboardingUseCaseViewModel = viewModel - let hostingController = VectorHostingController(rootView: view) - hostingController.vc_removeBackTitle() - hostingController.enableNavigationBarScrollEdgeAppearance = true - onboardingUseCaseHostingController = hostingController + onboardingUseCaseHostingController = VectorHostingController(rootView: view) + onboardingUseCaseHostingController.vc_removeBackTitle() + onboardingUseCaseHostingController.enableNavigationBarScrollEdgeAppearance = true } // MARK: - Public From 059a8181ed928f3fe4d8275b51634a6666a061af Mon Sep 17 00:00:00 2001 From: Doug Date: Tue, 15 Mar 2022 11:51:17 +0000 Subject: [PATCH 29/38] Add onboarding avatar screen. Includes a PhotoPickerPresenter to pick photos without requesting permissions. --- Config/BuildSettings.swift | 2 +- .../Contents.json | 15 ++ .../onboarding_avatar_camera.svg | 4 + .../Contents.json | 15 ++ .../onboarding_avatar_edit.svg | 4 + Riot/Assets/en.lproj/Untranslated.strings | 17 +- Riot/Generated/Images.swift | 2 + Riot/Generated/UntranslatedStrings.swift | 34 ++-- .../Onboarding/OnboardingCoordinator.swift | 88 ++++++++- .../PhotoPicker/PhotoPickerPresenter.swift | 91 +++++++++ .../Common/Avatar/View/AvatarImage.swift | 15 +- .../Avatar/View/PlaceholderAvatarImage.swift | 55 ++++++ .../Avatar/ViewModel/AvatarViewModel.swift | 36 +--- .../PlaceholderAvatarViewModel.swift | 50 +++++ .../OnboardingAvatarCoordinator.swift | 179 ++++++++++++++++++ .../MockOnboardingAvatarScreenState.swift | 71 +++++++ .../Avatar/OnboardingAvatarModels.swift | 51 +++++ .../Avatar/OnboardingAvatarViewModel.swift | 75 ++++++++ .../OnboardingAvatarViewModelProtocol.swift | 29 +++ .../Test/UI/OnboardingAvatarUITests.swift | 53 ++++++ .../Unit/OnboardingAvatarViewModelTests.swift | 57 ++++++ .../Avatar/View/OnboardingAvatarScreen.swift | 145 ++++++++++++++ ...OnboardingCongratulationsCoordinator.swift | 5 +- ...OnboardingCongratulationsScreenState.swift | 15 +- .../OnboardingCongratulationsModels.swift | 3 +- .../OnboardingCongratulationsViewModel.swift | 5 +- .../OnboardingCongratulationsScreen.swift | 27 ++- .../OnboardingDisplayNameCoordinator.swift | 1 + .../View/OnboardingDisplayNameScreen.swift | 33 +++- 29 files changed, 1090 insertions(+), 87 deletions(-) create mode 100644 Riot/Assets/Images.xcassets/Onboarding/onboarding_avatar_camera.imageset/Contents.json create mode 100644 Riot/Assets/Images.xcassets/Onboarding/onboarding_avatar_camera.imageset/onboarding_avatar_camera.svg create mode 100644 Riot/Assets/Images.xcassets/Onboarding/onboarding_avatar_edit.imageset/Contents.json create mode 100644 Riot/Assets/Images.xcassets/Onboarding/onboarding_avatar_edit.imageset/onboarding_avatar_edit.svg create mode 100644 Riot/Modules/PhotoPicker/PhotoPickerPresenter.swift create mode 100644 RiotSwiftUI/Modules/Common/Avatar/View/PlaceholderAvatarImage.swift create mode 100644 RiotSwiftUI/Modules/Common/Avatar/ViewModel/PlaceholderAvatarViewModel.swift create mode 100644 RiotSwiftUI/Modules/Onboarding/Avatar/Coordinator/OnboardingAvatarCoordinator.swift create mode 100644 RiotSwiftUI/Modules/Onboarding/Avatar/MockOnboardingAvatarScreenState.swift create mode 100644 RiotSwiftUI/Modules/Onboarding/Avatar/OnboardingAvatarModels.swift create mode 100644 RiotSwiftUI/Modules/Onboarding/Avatar/OnboardingAvatarViewModel.swift create mode 100644 RiotSwiftUI/Modules/Onboarding/Avatar/OnboardingAvatarViewModelProtocol.swift create mode 100644 RiotSwiftUI/Modules/Onboarding/Avatar/Test/UI/OnboardingAvatarUITests.swift create mode 100644 RiotSwiftUI/Modules/Onboarding/Avatar/Test/Unit/OnboardingAvatarViewModelTests.swift create mode 100644 RiotSwiftUI/Modules/Onboarding/Avatar/View/OnboardingAvatarScreen.swift diff --git a/Config/BuildSettings.swift b/Config/BuildSettings.swift index b9c639f15..8d3767215 100644 --- a/Config/BuildSettings.swift +++ b/Config/BuildSettings.swift @@ -369,7 +369,7 @@ final class BuildSettings: NSObject { static let authEnableRefreshTokens = false // MARK: - Onboarding - static let onboardingShowAccountPersonalisation = false + static let onboardingShowAccountPersonalization = false // MARK: - Unified Search static let unifiedSearchScreenShowPublicDirectory = true diff --git a/Riot/Assets/Images.xcassets/Onboarding/onboarding_avatar_camera.imageset/Contents.json b/Riot/Assets/Images.xcassets/Onboarding/onboarding_avatar_camera.imageset/Contents.json new file mode 100644 index 000000000..98829d4af --- /dev/null +++ b/Riot/Assets/Images.xcassets/Onboarding/onboarding_avatar_camera.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "onboarding_avatar_camera.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Riot/Assets/Images.xcassets/Onboarding/onboarding_avatar_camera.imageset/onboarding_avatar_camera.svg b/Riot/Assets/Images.xcassets/Onboarding/onboarding_avatar_camera.imageset/onboarding_avatar_camera.svg new file mode 100644 index 000000000..50893f857 --- /dev/null +++ b/Riot/Assets/Images.xcassets/Onboarding/onboarding_avatar_camera.imageset/onboarding_avatar_camera.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Riot/Assets/Images.xcassets/Onboarding/onboarding_avatar_edit.imageset/Contents.json b/Riot/Assets/Images.xcassets/Onboarding/onboarding_avatar_edit.imageset/Contents.json new file mode 100644 index 000000000..7026b6d89 --- /dev/null +++ b/Riot/Assets/Images.xcassets/Onboarding/onboarding_avatar_edit.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "onboarding_avatar_edit.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Riot/Assets/Images.xcassets/Onboarding/onboarding_avatar_edit.imageset/onboarding_avatar_edit.svg b/Riot/Assets/Images.xcassets/Onboarding/onboarding_avatar_edit.imageset/onboarding_avatar_edit.svg new file mode 100644 index 000000000..f17013d91 --- /dev/null +++ b/Riot/Assets/Images.xcassets/Onboarding/onboarding_avatar_edit.imageset/onboarding_avatar_edit.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Riot/Assets/en.lproj/Untranslated.strings b/Riot/Assets/en.lproj/Untranslated.strings index b1ec8050e..d9fd4d3e5 100644 --- a/Riot/Assets/en.lproj/Untranslated.strings +++ b/Riot/Assets/en.lproj/Untranslated.strings @@ -16,15 +16,22 @@ /** These strings will be ignored by Weblate. Useful for WIP **/ -// MARK: Onboarding Personalisation WIP +// MARK: Onboarding Personalization WIP "onboarding_congratulations_title" = "Congratulations!"; -"onboarding_congratulations_message" = "Your account\n%@\nhas been created."; -"onboarding_congratulations_personalise_button" = "Personalise profile"; +"onboarding_congratulations_message" = "Your account %@ has been created."; +"onboarding_congratulations_personalize_button" = "Personalise profile"; "onboarding_congratulations_home_button" = "Take me home"; +"onboarding_personalization_save" = "Save and continue"; +"onboarding_personalization_skip" = "Skip this step"; + "onboarding_display_name_title" = "Choose a display name"; "onboarding_display_name_message" = "This will be shown when you send messages."; "onboarding_display_name_placeholder" = "Display Name"; "onboarding_display_name_hint" = "You can change this later"; -"onboarding_display_name_save" = "Save and continue"; -"onboarding_display_name_skip" = "Skip this step"; + +"onboarding_avatar_title" = "Add a profile picture"; +"onboarding_avatar_message" = "You can change this anytime."; + + +"image_picker_action_files" = "Choose from files"; diff --git a/Riot/Generated/Images.swift b/Riot/Generated/Images.swift index 0a78fb517..469b4b1e7 100644 --- a/Riot/Generated/Images.swift +++ b/Riot/Generated/Images.swift @@ -124,6 +124,8 @@ internal class Asset: NSObject { internal static let onboardingSplashScreenPage3Dark = ImageAsset(name: "OnboardingSplashScreenPage3Dark") internal static let onboardingSplashScreenPage4 = ImageAsset(name: "OnboardingSplashScreenPage4") internal static let onboardingSplashScreenPage4Dark = ImageAsset(name: "OnboardingSplashScreenPage4Dark") + internal static let onboardingAvatarCamera = ImageAsset(name: "onboarding_avatar_camera") + internal static let onboardingAvatarEdit = ImageAsset(name: "onboarding_avatar_edit") internal static let onboardingCongratulationsIcon = ImageAsset(name: "onboarding_congratulations_icon") internal static let onboardingUseCaseCommunity = ImageAsset(name: "onboarding_use_case_community") internal static let onboardingUseCaseCommunityDark = ImageAsset(name: "onboarding_use_case_community_dark") diff --git a/Riot/Generated/UntranslatedStrings.swift b/Riot/Generated/UntranslatedStrings.swift index d98fd9680..9370e608f 100644 --- a/Riot/Generated/UntranslatedStrings.swift +++ b/Riot/Generated/UntranslatedStrings.swift @@ -10,17 +10,29 @@ import Foundation // swiftlint:disable function_parameter_count identifier_name line_length type_body_length public extension VectorL10n { + /// Choose from files + static var imagePickerActionFiles: String { + return VectorL10n.tr("Untranslated", "image_picker_action_files") + } + /// You can change this anytime. + static var onboardingAvatarMessage: String { + return VectorL10n.tr("Untranslated", "onboarding_avatar_message") + } + /// Add a profile picture + static var onboardingAvatarTitle: String { + return VectorL10n.tr("Untranslated", "onboarding_avatar_title") + } /// Take me home static var onboardingCongratulationsHomeButton: String { return VectorL10n.tr("Untranslated", "onboarding_congratulations_home_button") } - /// Your account\n%@\nhas been created. + /// Your account %@ has been created. public static func onboardingCongratulationsMessage(_ p1: String) -> String { return VectorL10n.tr("Untranslated", "onboarding_congratulations_message", p1) } /// Personalise profile - static var onboardingCongratulationsPersonaliseButton: String { - return VectorL10n.tr("Untranslated", "onboarding_congratulations_personalise_button") + static var onboardingCongratulationsPersonalizeButton: String { + return VectorL10n.tr("Untranslated", "onboarding_congratulations_personalize_button") } /// Congratulations! static var onboardingCongratulationsTitle: String { @@ -38,18 +50,18 @@ public extension VectorL10n { static var onboardingDisplayNamePlaceholder: String { return VectorL10n.tr("Untranslated", "onboarding_display_name_placeholder") } - /// Save and continue - static var onboardingDisplayNameSave: String { - return VectorL10n.tr("Untranslated", "onboarding_display_name_save") - } - /// Skip this step - static var onboardingDisplayNameSkip: String { - return VectorL10n.tr("Untranslated", "onboarding_display_name_skip") - } /// Choose a display name static var onboardingDisplayNameTitle: String { return VectorL10n.tr("Untranslated", "onboarding_display_name_title") } + /// Save and continue + static var onboardingPersonalizationSave: String { + return VectorL10n.tr("Untranslated", "onboarding_personalization_save") + } + /// Skip this step + static var onboardingPersonalizationSkip: String { + return VectorL10n.tr("Untranslated", "onboarding_personalization_skip") + } } // swiftlint:enable function_parameter_count identifier_name line_length type_body_length diff --git a/Riot/Modules/Onboarding/OnboardingCoordinator.swift b/Riot/Modules/Onboarding/OnboardingCoordinator.swift index 63d0e47cc..52d977f0f 100644 --- a/Riot/Modules/Onboarding/OnboardingCoordinator.swift +++ b/Riot/Modules/Onboarding/OnboardingCoordinator.swift @@ -65,6 +65,9 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { private var authenticationType: MXKAuthenticationType? private var session: MXSession? + private var shouldShowDisplayNameScreen = false + private var shouldShowAvatarScreen = false + /// Whether all of the onboarding steps have been completed or not. `false` if there are more screens to be shown. private var onboardingFinished = false /// Whether authentication is complete. `true` once authenticated, verified and the app is ready to be shown. @@ -255,8 +258,8 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { if authenticationType == .register, let userId = session.credentials.userId, let userSession = UserSessionsService.shared.userSession(withUserId: userId), - BuildSettings.onboardingShowAccountPersonalisation { - showCongratulationsScreen(userSession: userSession) + BuildSettings.onboardingShowAccountPersonalization { + checkHomeserverCapabilities(for: userSession) return } else if Analytics.shared.shouldShowAnalyticsPrompt { showAnalyticsPrompt(for: session) @@ -269,6 +272,20 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { completeIfReady() } + @available(iOS 14.0, *) + private func checkHomeserverCapabilities(for userSession: UserSession) { + userSession.matrixSession.matrixRestClient.capabilities { [weak self] capabilities in + guard let self = self else { return } + self.shouldShowDisplayNameScreen = capabilities?.setDisplayName?.isEnabled == true + self.shouldShowAvatarScreen = capabilities?.setAvatarUrl?.isEnabled == true + + self.beginPostAuthentication(for: userSession) + } failure: { [weak self] _ in + MXLog.warning("[OnboardingCoordinator] Homeserver capabilities not returned. Skipping personalisation") + self?.beginPostAuthentication(for: userSession) + } + } + /// Displays the next view in the flow after the authentication screen. private func authenticationCoordinatorDidComplete(_ coordinator: AuthenticationCoordinatorProtocol) { isShowingAuthentication = false @@ -292,10 +309,16 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { // MARK: - Post-Authentication @available(iOS 14.0, *) - private func showCongratulationsScreen(userSession: UserSession) { + private func beginPostAuthentication(for userSession: UserSession) { + showCongratulationsScreen(for: userSession) + } + + @available(iOS 14.0, *) + private func showCongratulationsScreen(for userSession: UserSession) { MXLog.debug("[OnboardingCoordinator] showCongratulationsScreen") - let parameters = OnboardingCongratulationsCoordinatorParameters(userSession: userSession) + let parameters = OnboardingCongratulationsCoordinatorParameters(userSession: userSession, + personalizationDisabled: !shouldShowDisplayNameScreen && !shouldShowAvatarScreen) let coordinator = OnboardingCongratulationsCoordinator(parameters: parameters) coordinator.completion = { [weak self, weak coordinator] result in @@ -316,15 +339,25 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { private func congratulationsCoordinator(_ coordinator: OnboardingCongratulationsCoordinator, didCompleteWith result: OnboardingCongratulationsCoordinatorResult) { switch result { case .personaliseProfile(let userSession): - #warning("Check server capabilities first") - showDisplayNameScreen(for: userSession) - return + if shouldShowDisplayNameScreen { + showDisplayNameScreen(for: userSession) + return + } else if shouldShowAvatarScreen { + showAvatarScreen(for: userSession) + return + } else if Analytics.shared.shouldShowAnalyticsPrompt { + showAnalyticsPrompt(for: userSession.matrixSession) + return + } case .takeMeHome(let userSession): if Analytics.shared.shouldShowAnalyticsPrompt { showAnalyticsPrompt(for: userSession.matrixSession) return } } + + onboardingFinished = true + completeIfReady() } @available(iOS 14.0, *) @@ -349,7 +382,46 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { @available(iOS 14.0, *) private func displayNameCoordinator(_ coordinator: OnboardingDisplayNameCoordinator, didCompleteWith userSession: UserSession) { - // TODO: Show Avatar screen. + if shouldShowAvatarScreen { + showAvatarScreen(for: userSession) + return + } else if Analytics.shared.shouldShowAnalyticsPrompt { + showAnalyticsPrompt(for: userSession.matrixSession) + } + + onboardingFinished = true + completeIfReady() + } + + @available(iOS 14.0, *) + private func showAvatarScreen(for userSession: UserSession) { + MXLog.debug("[OnboardingCoordinator]: showAvatarScreen") + + let parameters = OnboardingAvatarCoordinatorParameters(userSession: userSession) + let coordinator = OnboardingAvatarCoordinator(parameters: parameters) + + coordinator.completion = { [weak self, weak coordinator] session in + guard let self = self, let coordinator = coordinator else { return } + self.avatarCoordinator(coordinator, didCompleteWith: session) + } + + add(childCoordinator: coordinator) + coordinator.start() + + #warning("Should become root if display name was disabled.") + if navigationRouter.modules.isEmpty { + navigationRouter.setRootModule(coordinator, hideNavigationBar: false, animated: true) { [weak self] in + self?.remove(childCoordinator: coordinator) + } + } else { + navigationRouter.push(coordinator, animated: true) { [weak self] in + self?.remove(childCoordinator: coordinator) + } + } + } + + @available(iOS 14.0, *) + private func avatarCoordinator(_ coordinator: OnboardingAvatarCoordinator, didCompleteWith userSession: UserSession) { if Analytics.shared.shouldShowAnalyticsPrompt { showAnalyticsPrompt(for: userSession.matrixSession) return diff --git a/Riot/Modules/PhotoPicker/PhotoPickerPresenter.swift b/Riot/Modules/PhotoPicker/PhotoPickerPresenter.swift new file mode 100644 index 000000000..6a13a1ff4 --- /dev/null +++ b/Riot/Modules/PhotoPicker/PhotoPickerPresenter.swift @@ -0,0 +1,91 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import UIKit +import PhotosUI + +@available(iOS 14.0, *) +protocol PhotoPickerPresenterDelegate: AnyObject { + func photoPickerPresenter(_ presenter: PhotoPickerPresenter, didPickImage image: UIImage) + func photoPickerPresenterDidCancel(_ presenter: PhotoPickerPresenter) +} + +/// A picker for photos and videos from the user's photo library on iOS 14+ using the +/// new `PHPickerViewController` that doesn't require permission to be granted. +@available(iOS 14.0, *) +final class PhotoPickerPresenter: NSObject { + + // MARK: - Properties + + // MARK: Private + + private weak var pickerViewController: UIViewController? + private var filter: PHPickerFilter? + + // MARK: Public + + weak var delegate: PhotoPickerPresenterDelegate? + + // MARK: - Public + + func presentPicker(from presentingViewController: UIViewController, with filter: PHPickerFilter?, animated: Bool) { + var configuration = PHPickerConfiguration(photoLibrary: .shared()) + configuration.selectionLimit = 1 + configuration.filter = filter + + let pickerViewController = PHPickerViewController(configuration: configuration) + pickerViewController.delegate = self + + self.pickerViewController = pickerViewController + + presentingViewController.present(pickerViewController, animated: true, completion: nil) + } + + func dismiss(animated: Bool, completion: (() -> Void)?) { + guard let pickerViewController = pickerViewController else { return } + pickerViewController.dismiss(animated: animated, completion: completion) + } +} + +// MARK: - PHPickerViewControllerDelegate +@available(iOS 14, *) +extension PhotoPickerPresenter: PHPickerViewControllerDelegate { + func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + // TODO: Handle videos and multi-selection + guard + let provider = results.first?.itemProvider, + provider.canLoadObject(ofClass: UIImage.self) + else { + self.delegate?.photoPickerPresenterDidCancel(self) + return + } + + provider.loadObject(ofClass: UIImage.self) { [weak self] image, error in + guard let self = self else { return } + + guard let image = image as? UIImage else { + DispatchQueue.main.async { + self.delegate?.photoPickerPresenterDidCancel(self) + } + return + } + + DispatchQueue.main.async { + self.delegate?.photoPickerPresenter(self, didPickImage: image) + } + } + } +} diff --git a/RiotSwiftUI/Modules/Common/Avatar/View/AvatarImage.swift b/RiotSwiftUI/Modules/Common/Avatar/View/AvatarImage.swift index ccc315a73..5bed53bd5 100644 --- a/RiotSwiftUI/Modules/Common/Avatar/View/AvatarImage.swift +++ b/RiotSwiftUI/Modules/Common/Avatar/View/AvatarImage.swift @@ -35,22 +35,15 @@ struct AvatarImage: View { case .empty: ProgressView() case .placeholder(let firstCharacter, let colorIndex): - Text(firstCharacter) - .padding(4) - .frame(width: CGFloat(size.rawValue), height: CGFloat(size.rawValue)) - .foregroundColor(.white) - .background(theme.colors.namesAndAvatars[colorIndex]) - .clipShape(Circle()) - // Make the text resizable (i.e. Make it large and then allow it to scale down) - .font(.system(size: 200)) - .minimumScaleFactor(0.001) + PlaceholderAvatarImage(firstCharacter: firstCharacter, + colorIndex: colorIndex) case .avatar(let image): Image(uiImage: image) .resizable() - .frame(width: CGFloat(size.rawValue), height: CGFloat(size.rawValue)) - .clipShape(Circle()) } } + .frame(width: CGFloat(size.rawValue), height: CGFloat(size.rawValue)) + .clipShape(Circle()) .onAppear { viewModel.inject(dependencies: dependencies) viewModel.loadAvatar( diff --git a/RiotSwiftUI/Modules/Common/Avatar/View/PlaceholderAvatarImage.swift b/RiotSwiftUI/Modules/Common/Avatar/View/PlaceholderAvatarImage.swift new file mode 100644 index 000000000..f0f386eec --- /dev/null +++ b/RiotSwiftUI/Modules/Common/Avatar/View/PlaceholderAvatarImage.swift @@ -0,0 +1,55 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 PlaceholderAvatarImage: View { + + // MARK: - Private + + @Environment(\.theme) private var theme + + // MARK: - Public + + let firstCharacter: String + let colorIndex: Int + + // MARK: - Views + + var body: some View { + ZStack { + theme.colors.namesAndAvatars[colorIndex] + + Text(firstCharacter) + .padding(4) + .foregroundColor(.white) + // Make the text resizable (i.e. Make it large and then allow it to scale down) + .font(.system(size: 200)) + .minimumScaleFactor(0.001) + } + .aspectRatio(1, contentMode: .fill) + } +} + +@available(iOS 14.0, *) +struct Previews_TemplateAvatarImage_Previews: PreviewProvider { + static var previews: some View { + PlaceholderAvatarImage(firstCharacter: "X", colorIndex: 1) + .clipShape(Circle()) + .frame(width: 150, height: 100) + } +} diff --git a/RiotSwiftUI/Modules/Common/Avatar/ViewModel/AvatarViewModel.swift b/RiotSwiftUI/Modules/Common/Avatar/ViewModel/AvatarViewModel.swift index 1a5f8f032..2b49dc289 100644 --- a/RiotSwiftUI/Modules/Common/Avatar/ViewModel/AvatarViewModel.swift +++ b/RiotSwiftUI/Modules/Common/Avatar/ViewModel/AvatarViewModel.swift @@ -42,10 +42,11 @@ class AvatarViewModel: InjectableObject, ObservableObject { colorCount: Int, avatarSize: AvatarSize) { - self.viewState = .placeholder( - firstCharacterCapitalized(displayName), - stableColorIndex(matrixItemId: matrixItemId, colorCount: colorCount) - ) + let placeholderViewModel = PlaceholderAvatarViewModel(displayName: displayName, + matrixItemId: matrixItemId, + colorCount: colorCount) + + self.viewState = .placeholder(placeholderViewModel.firstCharacterCapitalized, placeholderViewModel.stableColorIndex) guard let mxContentUri = mxContentUri, mxContentUri.count > 0 else { return @@ -60,31 +61,4 @@ class AvatarViewModel: InjectableObject, ObservableObject { } .store(in: &cancellables) } - - /// Get the first character of a string capialized or else an empty string. - /// - Parameter string: The input string to get the capitalized letter from. - /// - Returns: The capitalized first letter. - private func firstCharacterCapitalized(_ string: String?) -> String { - guard let character = string?.first else { - return "" - } - return String(character).capitalized - } - - /// Provides the same color each time for a specified matrixId - /// - /// Same algorithm as in AvatarGenerator. - /// - Parameters: - /// - matrixItemId: the matrix id used as input to create the stable index. - /// - colorCount: The number of total colors we want to index in to. - /// - Returns: The stable index. - private func stableColorIndex(matrixItemId: String, colorCount: Int) -> Int { - // Sum all characters - let sum = matrixItemId.utf8 - .map({ UInt($0) }) - .reduce(0, +) - // modulo the color count - return Int(sum) % colorCount - } - } diff --git a/RiotSwiftUI/Modules/Common/Avatar/ViewModel/PlaceholderAvatarViewModel.swift b/RiotSwiftUI/Modules/Common/Avatar/ViewModel/PlaceholderAvatarViewModel.swift new file mode 100644 index 000000000..806f24c97 --- /dev/null +++ b/RiotSwiftUI/Modules/Common/Avatar/ViewModel/PlaceholderAvatarViewModel.swift @@ -0,0 +1,50 @@ +// +// Copyright 2022 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 + +/// Simple view model that computes the placeholder avatar properties. +struct PlaceholderAvatarViewModel { + /// The displayname used to create the `firstCharacterCapitalized`. + let displayName: String? + /// The matrix id used as input to create the `stableColorIndex` from. + let matrixItemId: String + /// The number of total colors available for the `stableColorIndex`. + let colorCount: Int + + /// Get the first character of the display name capitalized or else an empty string. + var firstCharacterCapitalized: String { + guard let character = displayName?.first else { + return "" + } + return String(character).capitalized + } + + /// Provides the same color each time for a specified matrixId + /// + /// Same algorithm as in AvatarGenerator. + /// - Parameters: + /// - matrixItemId: the matrix id used as input to create the stable index. + /// - Returns: The stable index. + var stableColorIndex: Int { + // Sum all characters + let sum = matrixItemId.utf8 + .map({ UInt($0) }) + .reduce(0, +) + // modulo the color count + return Int(sum) % colorCount + } +} diff --git a/RiotSwiftUI/Modules/Onboarding/Avatar/Coordinator/OnboardingAvatarCoordinator.swift b/RiotSwiftUI/Modules/Onboarding/Avatar/Coordinator/OnboardingAvatarCoordinator.swift new file mode 100644 index 000000000..e88a80086 --- /dev/null +++ b/RiotSwiftUI/Modules/Onboarding/Avatar/Coordinator/OnboardingAvatarCoordinator.swift @@ -0,0 +1,179 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 MatrixSDK + +struct OnboardingAvatarCoordinatorParameters { + let userSession: UserSession +} + +@available(iOS 14.0, *) +final class OnboardingAvatarCoordinator: Coordinator, Presentable { + + // MARK: - Properties + + // MARK: Private + + private let parameters: OnboardingAvatarCoordinatorParameters + private let onboardingAvatarHostingController: VectorHostingController + private var onboardingAvatarViewModel: OnboardingAvatarViewModelProtocol + + private lazy var cameraPresenter: CameraPresenter = { + let presenter = CameraPresenter() + presenter.delegate = self + return presenter + }() + + private lazy var photoPickerPresenter: PhotoPickerPresenter = { + let presenter = PhotoPickerPresenter() + presenter.delegate = self + return presenter + }() + + // MARK: Public + + // Must be used only internally + var childCoordinators: [Coordinator] = [] + var completion: ((UserSession) -> Void)? + + // MARK: - Setup + + init(parameters: OnboardingAvatarCoordinatorParameters) { + self.parameters = parameters + let viewModel = OnboardingAvatarViewModel(userId: parameters.userSession.userId, + displayName: parameters.userSession.account.userDisplayName, + avatarColorCount: DefaultThemeSwiftUI().colors.namesAndAvatars.count) + let view = OnboardingAvatarScreen(viewModel: viewModel.context) + onboardingAvatarViewModel = viewModel + onboardingAvatarHostingController = VectorHostingController(rootView: view) + onboardingAvatarHostingController.vc_removeBackTitle() + onboardingAvatarHostingController.enableNavigationBarScrollEdgesAppearance = true + } + + + // MARK: - Public + + func start() { + MXLog.debug("[OnboardingAvatarCoordinator] did start.") + onboardingAvatarViewModel.completion = { [weak self] result in + guard let self = self else { return } + MXLog.debug("[OnboardingAvatarCoordinator] OnboardingAvatarViewModel did complete with result: \(result).") + switch result { + case .pickImage: + self.pickImage() + case .takePhoto: + self.takePhoto() + case .save(let avatar): + self.setAvatar(avatar) + case .skip: + self.completion?(self.parameters.userSession) + } + } + } + + func toPresentable() -> UIViewController { + return self.onboardingAvatarHostingController + } + + // MARK: - Private + + private func pickImage() { + let controller = toPresentable() + photoPickerPresenter.presentPicker(from: controller, with: .images, animated: true) + } + + private func takePhoto() { + let controller = toPresentable() + cameraPresenter.presentCamera(from: controller, with: [.image], animated: true) + } + + private lazy var mediaUploader: MXMediaLoader = MXMediaManager.prepareUploader(withMatrixSession: parameters.userSession.matrixSession, + initialRange: 0, + andRange: 1.0) + + #warning("Temporary") + func unknownError() -> Error { + MXError(errorCode: "M.UNKNOWN", error: "Something went wrong!").createNSError() + } + + func setAvatar(_ image: UIImage?) { + guard let image = image else { + MXLog.error("[OnboardingAvatarCoordinator] setAvatar called with a nil image.") + return + } + + onboardingAvatarViewModel.startLoading() + + guard let avatarData = MXKTools.forceImageOrientationUp(image)?.jpegData(compressionQuality: 0.5) else { + MXLog.error("[OnboardingAvatarCoordinator] Failed to create jpeg data.") + self.onboardingAvatarViewModel.stopLoading(error: self.unknownError()) + return + } + + mediaUploader.uploadData(avatarData, filename: nil, mimeType: "image/jpeg") { [weak self] urlString in + guard let self = self else { return } + + guard let urlString = urlString else { + self.onboardingAvatarViewModel.stopLoading(error: self.unknownError()) + return + } + + self.parameters.userSession.account.setUserAvatarUrl(urlString) { [weak self] in + guard let self = self else { return } + self.completion?(self.parameters.userSession) + } failure: { [weak self] error in + guard let self = self else { return } + self.onboardingAvatarViewModel.stopLoading(error: error ?? self.unknownError()) + } + } failure: { [weak self] error in + guard let self = self else { return } + self.onboardingAvatarViewModel.stopLoading(error: error ?? self.unknownError()) + } + } +} + +// MARK: - PhotoPickerPresenterDelegate + +@available(iOS 14.0, *) +extension OnboardingAvatarCoordinator: PhotoPickerPresenterDelegate { + func photoPickerPresenter(_ presenter: PhotoPickerPresenter, didPickImage image: UIImage) { + onboardingAvatarViewModel.updateAvatarImage(with: image) + presenter.dismiss(animated: true, completion: nil) + } + + func photoPickerPresenterDidCancel(_ presenter: PhotoPickerPresenter) { + presenter.dismiss(animated: true, completion: nil) + } +} + +// MARK: - CameraPresenterDelegate + +@available(iOS 14.0, *) +extension OnboardingAvatarCoordinator: CameraPresenterDelegate { + func cameraPresenter(_ presenter: CameraPresenter, didSelectImageData imageData: Data, withUTI uti: MXKUTI?) { + onboardingAvatarViewModel.updateAvatarImage(with: UIImage(data: imageData)) + presenter.dismiss(animated: true, completion: nil) + } + + func cameraPresenter(_ presenter: CameraPresenter, didSelectVideoAt url: URL) { + presenter.dismiss(animated: true, completion: nil) + } + + func cameraPresenterDidCancel(_ presenter: CameraPresenter) { + presenter.dismiss(animated: true, completion: nil) + } +} diff --git a/RiotSwiftUI/Modules/Onboarding/Avatar/MockOnboardingAvatarScreenState.swift b/RiotSwiftUI/Modules/Onboarding/Avatar/MockOnboardingAvatarScreenState.swift new file mode 100644 index 000000000..c151546ec --- /dev/null +++ b/RiotSwiftUI/Modules/Onboarding/Avatar/MockOnboardingAvatarScreenState.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 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 MockOnboardingAvatarScreenState: MockScreenState, CaseIterable { + // A case for each state you want to represent + // with specific, minimal associated data that will allow you + // mock that screen. + case placeholderAvatar(userId: String, displayName: String) + case userSelectedAvatar(userId: String, displayName: String, avatar: UIImage) + case waiting(userId: String, displayName: String?, avatar: UIImage) + + /// The associated screen + var screenType: Any.Type { + OnboardingAvatarScreen.self + } + + /// A list of screen state definitions + static var allCases: [MockOnboardingAvatarScreenState] { + let userId = "@example:matrix.org" + let displayName = "Jane" + let avatar = Asset.Images.appSymbol.image + + return [ + .placeholderAvatar(userId: userId, displayName: displayName), + .userSelectedAvatar(userId: userId, displayName: displayName, avatar: avatar), + .waiting(userId: userId, displayName: displayName, avatar: avatar) + ] + } + + /// Generate the view struct for the screen state. + var screenView: ([Any], AnyView) { + let avatarColorCount = DefaultThemeSwiftUI().colors.namesAndAvatars.count + let viewModel: OnboardingAvatarViewModel + switch self { + case .placeholderAvatar(let userId, let displayName): + viewModel = OnboardingAvatarViewModel(userId: userId, displayName: displayName, avatarColorCount: avatarColorCount) + case .userSelectedAvatar(let userId, let displayName, let avatar): + viewModel = OnboardingAvatarViewModel(userId: userId, displayName: displayName, avatarColorCount: avatarColorCount) + viewModel.updateAvatarImage(with: avatar) + case .waiting(let userId, let displayName, let avatar): + viewModel = OnboardingAvatarViewModel(userId: userId, displayName: displayName, avatarColorCount: avatarColorCount) + viewModel.updateAvatarImage(with: avatar) + viewModel.startLoading() + } + + return ( + [self, viewModel], + AnyView(OnboardingAvatarScreen(viewModel: viewModel.context) + .addDependency(MockAvatarService.example)) + ) + } +} diff --git a/RiotSwiftUI/Modules/Onboarding/Avatar/OnboardingAvatarModels.swift b/RiotSwiftUI/Modules/Onboarding/Avatar/OnboardingAvatarModels.swift new file mode 100644 index 000000000..4237bd1b6 --- /dev/null +++ b/RiotSwiftUI/Modules/Onboarding/Avatar/OnboardingAvatarModels.swift @@ -0,0 +1,51 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 + +// MARK: View model + +enum OnboardingAvatarViewModelResult { + case pickImage + case takePhoto + case save(UIImage?) + case skip +} + +// MARK: View + +struct OnboardingAvatarViewState: BindableState { + let placeholderAvatarLetter: String + let placeholderAvatarColorIndex: Int + var avatar: UIImage? + var isWaiting = false + var bindings: OnboardingAvatarBindings + + var buttonImage: ImageAsset { + avatar == nil ? Asset.Images.onboardingAvatarCamera : Asset.Images.onboardingAvatarEdit + } +} + +struct OnboardingAvatarBindings { + var alertInfo: AlertInfo? +} + +enum OnboardingAvatarViewAction { + case pickImage + case takePhoto + case save + case skip +} diff --git a/RiotSwiftUI/Modules/Onboarding/Avatar/OnboardingAvatarViewModel.swift b/RiotSwiftUI/Modules/Onboarding/Avatar/OnboardingAvatarViewModel.swift new file mode 100644 index 000000000..c9e45d0c5 --- /dev/null +++ b/RiotSwiftUI/Modules/Onboarding/Avatar/OnboardingAvatarViewModel.swift @@ -0,0 +1,75 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 OnboardingAvatarViewModelType = StateStoreViewModel +@available(iOS 14, *) +class OnboardingAvatarViewModel: OnboardingAvatarViewModelType, OnboardingAvatarViewModelProtocol { + + // MARK: - Properties + + // MARK: Private + + // MARK: Public + + var completion: ((OnboardingAvatarViewModelResult) -> Void)? + + // MARK: - Setup + + init(userId: String, displayName: String?, avatarColorCount: Int) { + let placeholderViewModel = PlaceholderAvatarViewModel(displayName: displayName, matrixItemId: userId, colorCount: avatarColorCount) + let initialViewState = OnboardingAvatarViewState(placeholderAvatarLetter: placeholderViewModel.firstCharacterCapitalized, + placeholderAvatarColorIndex: placeholderViewModel.stableColorIndex, + bindings: OnboardingAvatarBindings()) + super.init(initialViewState: initialViewState) + } + + // MARK: - Public + + override func process(viewAction: OnboardingAvatarViewAction) { + switch viewAction { + case .pickImage: + completion?(.pickImage) + case .takePhoto: + completion?(.takePhoto) + case .save: + completion?(.save(state.avatar)) + case .skip: + completion?(.skip) + } + } + + func updateAvatarImage(with image: UIImage?) { + state.avatar = image + } + + func startLoading() { + state.isWaiting = true + } + + func stopLoading(error: Error?) { + state.isWaiting = false + + if let error = error as NSError? { + state.bindings.alertInfo = AlertInfo(error: error) + } + } +} diff --git a/RiotSwiftUI/Modules/Onboarding/Avatar/OnboardingAvatarViewModelProtocol.swift b/RiotSwiftUI/Modules/Onboarding/Avatar/OnboardingAvatarViewModelProtocol.swift new file mode 100644 index 000000000..a068af94e --- /dev/null +++ b/RiotSwiftUI/Modules/Onboarding/Avatar/OnboardingAvatarViewModelProtocol.swift @@ -0,0 +1,29 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 + +protocol OnboardingAvatarViewModelProtocol { + + var completion: ((OnboardingAvatarViewModelResult) -> Void)? { get set } + @available(iOS 14, *) + var context: OnboardingAvatarViewModelType.Context { get } + + func updateAvatarImage(with image: UIImage?) + + func startLoading() + func stopLoading(error: Error?) +} diff --git a/RiotSwiftUI/Modules/Onboarding/Avatar/Test/UI/OnboardingAvatarUITests.swift b/RiotSwiftUI/Modules/Onboarding/Avatar/Test/UI/OnboardingAvatarUITests.swift new file mode 100644 index 000000000..0c356f221 --- /dev/null +++ b/RiotSwiftUI/Modules/Onboarding/Avatar/Test/UI/OnboardingAvatarUITests.swift @@ -0,0 +1,53 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 OnboardingAvatarUITests: MockScreenTest { + + override class var screenType: MockScreenState.Type { + return MockOnboardingAvatarScreenState.self + } + + override class func createTest() -> MockScreenTest { + return OnboardingAvatarUITests(selector: #selector(verifyOnboardingAvatarScreen)) + } + + func verifyOnboardingAvatarScreen() throws { + guard let screenState = screenState as? MockOnboardingAvatarScreenState else { fatalError("no screen") } + switch screenState { + case .presence(let presence): + verifyOnboardingAvatarPresence(presence: presence) + case .longDisplayName(let name): + verifyOnboardingAvatarLongName(name: name) + } + } + + func verifyOnboardingAvatarPresence(presence: OnboardingAvatarPresence) { + let presenceText = app.staticTexts["presenceText"] + XCTAssert(presenceText.exists) + XCTAssertEqual(presenceText.label, presence.title) + } + + func verifyOnboardingAvatarLongName(name: String) { + let displayNameText = app.staticTexts["displayNameText"] + XCTAssert(displayNameText.exists) + XCTAssertEqual(displayNameText.label, name) + } + +} diff --git a/RiotSwiftUI/Modules/Onboarding/Avatar/Test/Unit/OnboardingAvatarViewModelTests.swift b/RiotSwiftUI/Modules/Onboarding/Avatar/Test/Unit/OnboardingAvatarViewModelTests.swift new file mode 100644 index 000000000..92783d020 --- /dev/null +++ b/RiotSwiftUI/Modules/Onboarding/Avatar/Test/Unit/OnboardingAvatarViewModelTests.swift @@ -0,0 +1,57 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 OnboardingAvatarViewModelTests: XCTestCase { + private enum Constants { + static let presenceInitialValue: OnboardingAvatarPresence = .offline + static let displayName = "Alice" + } + var service: MockOnboardingAvatarService! + var viewModel: OnboardingAvatarViewModelProtocol! + var context: OnboardingAvatarViewModelType.Context! + var cancellables = Set() + override func setUpWithError() throws { + service = MockOnboardingAvatarService(displayName: Constants.displayName, presence: Constants.presenceInitialValue) + viewModel = OnboardingAvatarViewModel.makeOnboardingAvatarViewModel(onboardingAvatarService: 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: OnboardingAvatarPresence = .online + let newPresenceValue2: OnboardingAvatarPresence = .idle + service.simulateUpdate(presence: newPresenceValue1) + service.simulateUpdate(presence: newPresenceValue2) + XCTAssertEqual(try awaitDeferred(), [Constants.presenceInitialValue, newPresenceValue1, newPresenceValue2]) + } +} diff --git a/RiotSwiftUI/Modules/Onboarding/Avatar/View/OnboardingAvatarScreen.swift b/RiotSwiftUI/Modules/Onboarding/Avatar/View/OnboardingAvatarScreen.swift new file mode 100644 index 000000000..5e8f2ace2 --- /dev/null +++ b/RiotSwiftUI/Modules/Onboarding/Avatar/View/OnboardingAvatarScreen.swift @@ -0,0 +1,145 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 OnboardingAvatarScreen: View { + + // MARK: - Properties + + // MARK: Private + + @Environment(\.theme) private var theme + + @State private var isPresentingPickerSelection = false + + // MARK: Public + + @ObservedObject var viewModel: OnboardingAvatarViewModel.Context + + var body: some View { + ScrollView { + VStack(spacing: 0) { + avatar + .padding(.horizontal, 2) + .padding(.bottom, 40) + + header + .padding(.bottom, 40) + + buttons + } + .padding(.horizontal) + .padding(.top, 8) + .frame(maxHeight: .infinity) + } + .accentColor(theme.colors.accent) + .background(theme.colors.background.ignoresSafeArea()) + .waitOverlay(show: viewModel.viewState.isWaiting, allowUserInteraction: false) + .alert(item: $viewModel.alertInfo) { $0.alert } + } + + + /// The user's avatar along with a picker button + var avatar: some View { + Group { + if let avatarImage = viewModel.viewState.avatar { + Image(uiImage: avatarImage) + .resizable() + .scaledToFill() + } else { + PlaceholderAvatarImage(firstCharacter: viewModel.viewState.placeholderAvatarLetter, + colorIndex: viewModel.viewState.placeholderAvatarColorIndex) + } + } + .clipShape(Circle()) + .overlay(cameraButton, alignment: .bottomTrailing) + .frame(width: 120, height: 120) + .onTapGesture { isPresentingPickerSelection = true } + .actionSheet(isPresented: $isPresentingPickerSelection) { pickerSelectionActionSheet } + } + + /// The button to indicate the user can tap to select an avatar + /// Note: The whole avatar is tappable to make this easier. + var cameraButton: some View { + ZStack { + Circle() + .foregroundColor(theme.colors.background) + .shadow(color: .black.opacity(0.15), radius: 2.4, y: 2.4) + + Image(viewModel.viewState.buttonImage.name) + .renderingMode(.template) + .foregroundColor(theme.colors.secondaryContent) + } + .frame(width: 40, height: 40) + } + + /// The action sheet that asks how the user would like to set their avatar. + var pickerSelectionActionSheet: ActionSheet { + ActionSheet(title: Text(VectorL10n.onboardingAvatarTitle), buttons: [ + .default(Text(VectorL10n.imagePickerActionCamera)) { + viewModel.send(viewAction: .takePhoto) + }, + .default(Text(VectorL10n.imagePickerActionLibrary)) { + viewModel.send(viewAction: .pickImage) + }, + .cancel() + ]) + } + + /// The screen's title and message views. + var header: some View { + VStack(spacing: 8) { + Text(VectorL10n.onboardingAvatarTitle) + .font(theme.fonts.title2B) + .foregroundColor(theme.colors.primaryContent) + + Text(VectorL10n.onboardingAvatarMessage) + .font(theme.fonts.subheadline) + .foregroundColor(theme.colors.secondaryContent) + } + } + + /// The main action buttons in the form. + var buttons: some View { + VStack(spacing: 8) { + Button(VectorL10n.onboardingPersonalizationSave) { + viewModel.send(viewAction: .save) + } + .buttonStyle(PrimaryActionButtonStyle()) + .disabled(viewModel.viewState.avatar == nil || viewModel.viewState.isWaiting) + + Button { viewModel.send(viewAction: .skip) } label: { + Text(VectorL10n.onboardingPersonalizationSkip) + .font(theme.fonts.body) + .padding(12) + } + } + } +} + +// MARK: - Previews + +@available(iOS 14.0, *) +struct OnboardingAvatar_Previews: PreviewProvider { + static let stateRenderer = MockOnboardingAvatarScreenState.stateRenderer + static var previews: some View { + stateRenderer.screenGroup(addNavigation: true) + .navigationViewStyle(.stack) + } +} diff --git a/RiotSwiftUI/Modules/Onboarding/Congratulations/Coordinator/OnboardingCongratulationsCoordinator.swift b/RiotSwiftUI/Modules/Onboarding/Congratulations/Coordinator/OnboardingCongratulationsCoordinator.swift index c487829a4..78f8ea5be 100644 --- a/RiotSwiftUI/Modules/Onboarding/Congratulations/Coordinator/OnboardingCongratulationsCoordinator.swift +++ b/RiotSwiftUI/Modules/Onboarding/Congratulations/Coordinator/OnboardingCongratulationsCoordinator.swift @@ -18,6 +18,7 @@ import SwiftUI struct OnboardingCongratulationsCoordinatorParameters { let userSession: UserSession + let personalizationDisabled: Bool } enum OnboardingCongratulationsCoordinatorResult { @@ -47,7 +48,9 @@ final class OnboardingCongratulationsCoordinator: Coordinator, Presentable { init(parameters: OnboardingCongratulationsCoordinatorParameters) { self.parameters = parameters - let viewModel = OnboardingCongratulationsViewModel(userId: parameters.userSession.userId) + #warning("Add confetti when personalizationDisabled is false") + let viewModel = OnboardingCongratulationsViewModel(userId: parameters.userSession.userId, + personalizationDisabled: parameters.personalizationDisabled) let view = OnboardingCongratulationsScreen(viewModel: viewModel.context) onboardingCongratulationsViewModel = viewModel onboardingCongratulationsHostingController = VectorHostingController(rootView: view) diff --git a/RiotSwiftUI/Modules/Onboarding/Congratulations/MockOnboardingCongratulationsScreenState.swift b/RiotSwiftUI/Modules/Onboarding/Congratulations/MockOnboardingCongratulationsScreenState.swift index 4aa617f1c..5d114dca1 100644 --- a/RiotSwiftUI/Modules/Onboarding/Congratulations/MockOnboardingCongratulationsScreenState.swift +++ b/RiotSwiftUI/Modules/Onboarding/Congratulations/MockOnboardingCongratulationsScreenState.swift @@ -24,7 +24,8 @@ enum MockOnboardingCongratulationsScreenState: MockScreenState, CaseIterable { // A case for each state you want to represent // with specific, minimal associated data that will allow you // mock that screen. - case congratulations + case regular + case personalisationDisabled /// The associated screen var screenType: Any.Type { @@ -33,14 +34,18 @@ enum MockOnboardingCongratulationsScreenState: MockScreenState, CaseIterable { /// Generate the view struct for the screen state. var screenView: ([Any], AnyView) { - let viewModel = OnboardingCongratulationsViewModel(userId: "@testuser:example.com") + let viewModel: OnboardingCongratulationsViewModel - // can simulate service and viewModel actions here if needs be. + switch self { + case .regular: + viewModel = OnboardingCongratulationsViewModel(userId: "@testuser:example.com") + case .personalisationDisabled: + viewModel = OnboardingCongratulationsViewModel(userId: "@testuser:example.com", personalizationDisabled: true) + } return ( [self, viewModel], - AnyView(OnboardingCongratulationsScreen(viewModel: viewModel.context) - .addDependency(MockAvatarService.example)) + AnyView(OnboardingCongratulationsScreen(viewModel: viewModel.context)) ) } } diff --git a/RiotSwiftUI/Modules/Onboarding/Congratulations/OnboardingCongratulationsModels.swift b/RiotSwiftUI/Modules/Onboarding/Congratulations/OnboardingCongratulationsModels.swift index d8eb9e926..1c622a326 100644 --- a/RiotSwiftUI/Modules/Onboarding/Congratulations/OnboardingCongratulationsModels.swift +++ b/RiotSwiftUI/Modules/Onboarding/Congratulations/OnboardingCongratulationsModels.swift @@ -28,7 +28,8 @@ enum OnboardingCongratulationsViewModelResult { // MARK: View struct OnboardingCongratulationsViewState: BindableState { - var userId: String + let userId: String + let personalizationDisabled: Bool } enum OnboardingCongratulationsViewAction { diff --git a/RiotSwiftUI/Modules/Onboarding/Congratulations/OnboardingCongratulationsViewModel.swift b/RiotSwiftUI/Modules/Onboarding/Congratulations/OnboardingCongratulationsViewModel.swift index 26a7ebfdd..39c6ff468 100644 --- a/RiotSwiftUI/Modules/Onboarding/Congratulations/OnboardingCongratulationsViewModel.swift +++ b/RiotSwiftUI/Modules/Onboarding/Congratulations/OnboardingCongratulationsViewModel.swift @@ -33,8 +33,9 @@ class OnboardingCongratulationsViewModel: OnboardingCongratulationsViewModelType // MARK: - Setup - init(userId: String, initialCount: Int = 0) { - super.init(initialViewState: OnboardingCongratulationsViewState(userId: userId)) + init(userId: String, personalizationDisabled: Bool = false) { + super.init(initialViewState: OnboardingCongratulationsViewState(userId: userId, + personalizationDisabled: personalizationDisabled)) } // MARK: - Public diff --git a/RiotSwiftUI/Modules/Onboarding/Congratulations/View/OnboardingCongratulationsScreen.swift b/RiotSwiftUI/Modules/Onboarding/Congratulations/View/OnboardingCongratulationsScreen.swift index 892f904fc..85cb13571 100644 --- a/RiotSwiftUI/Modules/Onboarding/Congratulations/View/OnboardingCongratulationsScreen.swift +++ b/RiotSwiftUI/Modules/Onboarding/Congratulations/View/OnboardingCongratulationsScreen.swift @@ -45,7 +45,7 @@ struct OnboardingCongratulationsScreen: View { Spacer() - buttons + footer .padding(.horizontal, horizontalPadding) .padding(.bottom, 24) .padding(.bottom, geometry.safeAreaInsets.bottom > 0 ? 0 : 16) @@ -79,11 +79,20 @@ struct OnboardingCongratulationsScreen: View { } } - /// The action buttons shown at the bottom of the view. - var buttons: some View { + @ViewBuilder + var footer: some View { + if viewModel.viewState.personalizationDisabled { + homeButton + } else { + actionButtons + } + } + + /// The default action buttons shown at the bottom of the view. + var actionButtons: some View { VStack(spacing: 12) { Button { viewModel.send(viewAction: .personaliseProfile) } label: { - Text(VectorL10n.onboardingCongratulationsPersonaliseButton) + Text(VectorL10n.onboardingCongratulationsPersonalizeButton) .font(theme.fonts.bodySB) .foregroundColor(theme.colors.accent) } @@ -96,6 +105,16 @@ struct OnboardingCongratulationsScreen: View { } } } + + /// The single "Take me home" button shown when personlization isn't supported. + var homeButton: some View { + Button { viewModel.send(viewAction: .takeMeHome) } label: { + Text(VectorL10n.onboardingCongratulationsHomeButton) + .font(theme.fonts.bodySB) + .foregroundColor(theme.colors.accent) + } + .buttonStyle(PrimaryActionButtonStyle(customColor: .white)) + } } // MARK: - Previews diff --git a/RiotSwiftUI/Modules/Onboarding/DisplayName/Coordinator/OnboardingDisplayNameCoordinator.swift b/RiotSwiftUI/Modules/Onboarding/DisplayName/Coordinator/OnboardingDisplayNameCoordinator.swift index e604ffc16..45c134a16 100644 --- a/RiotSwiftUI/Modules/Onboarding/DisplayName/Coordinator/OnboardingDisplayNameCoordinator.swift +++ b/RiotSwiftUI/Modules/Onboarding/DisplayName/Coordinator/OnboardingDisplayNameCoordinator.swift @@ -45,6 +45,7 @@ final class OnboardingDisplayNameCoordinator: Coordinator, Presentable { let view = OnboardingDisplayNameScreen(viewModel: viewModel.context) onboardingDisplayNameViewModel = viewModel onboardingDisplayNameHostingController = VectorHostingController(rootView: view) + onboardingDisplayNameHostingController.vc_removeBackTitle() onboardingDisplayNameHostingController.enableNavigationBarScrollEdgesAppearance = true } diff --git a/RiotSwiftUI/Modules/Onboarding/DisplayName/View/OnboardingDisplayNameScreen.swift b/RiotSwiftUI/Modules/Onboarding/DisplayName/View/OnboardingDisplayNameScreen.swift index 21f7a9ad1..004ebc568 100644 --- a/RiotSwiftUI/Modules/Onboarding/DisplayName/View/OnboardingDisplayNameScreen.swift +++ b/RiotSwiftUI/Modules/Onboarding/DisplayName/View/OnboardingDisplayNameScreen.swift @@ -27,6 +27,19 @@ struct OnboardingDisplayNameScreen: View { @State private var isEditingTextField = false + #warning("Move these computed properties to the view model") + var textFieldFooterString: String { + if let errorMessage = viewModel.viewState.validationErrorMessage { + return errorMessage + } + + return VectorL10n.onboardingDisplayNameHint + } + + var textFieldFooterColor: Color { + viewModel.viewState.validationErrorMessage == nil ? theme.colors.tertiaryContent : theme.colors.alert + } + // MARK: Public @ObservedObject var viewModel: OnboardingDisplayNameViewModel.Context @@ -49,7 +62,11 @@ struct OnboardingDisplayNameScreen: View { } .accentColor(theme.colors.accent) .background(theme.colors.background.ignoresSafeArea()) + .waitOverlay(show: viewModel.viewState.isWaiting, allowUserInteraction: false) .alert(item: $viewModel.alertInfo) { $0.alert } + .onChange(of: viewModel.displayName) { _ in + viewModel.send(viewAction: .validateDisplayName) + } } /// The icon, title and message views. @@ -77,11 +94,13 @@ struct OnboardingDisplayNameScreen: View { TextField(VectorL10n.onboardingDisplayNamePlaceholder, text: $viewModel.displayName) { isEditingTextField = $0 } - .textFieldStyle(BorderedInputFieldStyle(theme: _theme, isEditing: isEditingTextField)) + .textFieldStyle(BorderedInputFieldStyle(theme: _theme, + isEditing: isEditingTextField, + isError: viewModel.viewState.validationErrorMessage != nil)) - Text(VectorL10n.onboardingDisplayNameHint) - .font(theme.fonts.caption2) - .foregroundColor(theme.colors.tertiaryContent) + Text(textFieldFooterString) + .font(theme.fonts.footnote) + .foregroundColor(textFieldFooterColor) .frame(maxWidth: .infinity, alignment: .leading) } } @@ -89,15 +108,15 @@ struct OnboardingDisplayNameScreen: View { /// The main action buttons in the form. var buttons: some View { VStack(spacing: 8) { - Button(VectorL10n.onboardingDisplayNameSave) { + Button(VectorL10n.onboardingPersonalizationSave) { viewModel.send(viewAction: .save) } .buttonStyle(PrimaryActionButtonStyle()) .disabled(viewModel.displayName.isEmpty || viewModel.viewState.isWaiting) - #warning("Use font/theme") Button { viewModel.send(viewAction: .skip) } label: { - Text(VectorL10n.onboardingDisplayNameSkip) + Text(VectorL10n.onboardingPersonalizationSkip) + .font(theme.fonts.body) .padding(12) } } From a8626557c1fffc2d6982cfc33139ae2e2fc5cd99 Mon Sep 17 00:00:00 2001 From: Doug Date: Tue, 15 Mar 2022 16:00:06 +0000 Subject: [PATCH 30/38] Use UserIndicatorPresenter for onboarding personalisation. Remove the service from the display name screen to match the avatar screen. Add a loading indicator to PhotoPickerPresenter. Fix layout issue when selecting non-square avatar image. --- Riot/Assets/en.lproj/Untranslated.strings | 1 + Riot/Assets/en.lproj/Vector.strings | 6 ++- Riot/Generated/Strings.swift | 7 +++ Riot/Generated/UntranslatedStrings.swift | 4 ++ .../PhotoPicker/PhotoPickerPresenter.swift | 20 +++++++ .../OnboardingAvatarCoordinator.swift | 39 ++++++++++---- .../MockOnboardingAvatarScreenState.swift | 8 +-- .../Avatar/OnboardingAvatarModels.swift | 1 - .../Avatar/OnboardingAvatarViewModel.swift | 8 +-- .../OnboardingAvatarViewModelProtocol.swift | 3 +- .../Avatar/View/OnboardingAvatarScreen.swift | 5 +- .../OnboardingDisplayNameCoordinator.swift | 49 +++++++++++++++-- ...MockOnboardingDisplayNameScreenState.swift | 15 ++---- .../OnboardingDisplayNameModels.swift | 10 +++- .../OnboardingDisplayNameViewModel.swift | 51 +++++++----------- ...boardingDisplayNameViewModelProtocol.swift | 6 +-- .../OnboardingDisplayNameService.swift | 52 ------------------- .../MockOnboardingDisplayNameService.swift | 35 ------------- ...OnboardingDisplayNameServiceProtocol.swift | 27 ---------- .../View/OnboardingDisplayNameScreen.swift | 18 ++----- 20 files changed, 158 insertions(+), 207 deletions(-) delete mode 100644 RiotSwiftUI/Modules/Onboarding/DisplayName/Service/MatrixSDK/OnboardingDisplayNameService.swift delete mode 100644 RiotSwiftUI/Modules/Onboarding/DisplayName/Service/Mock/MockOnboardingDisplayNameService.swift delete mode 100644 RiotSwiftUI/Modules/Onboarding/DisplayName/Service/OnboardingDisplayNameServiceProtocol.swift diff --git a/Riot/Assets/en.lproj/Untranslated.strings b/Riot/Assets/en.lproj/Untranslated.strings index d9fd4d3e5..2f7e29d1d 100644 --- a/Riot/Assets/en.lproj/Untranslated.strings +++ b/Riot/Assets/en.lproj/Untranslated.strings @@ -29,6 +29,7 @@ "onboarding_display_name_message" = "This will be shown when you send messages."; "onboarding_display_name_placeholder" = "Display Name"; "onboarding_display_name_hint" = "You can change this later"; +"onboarding_display_name_max_length" = "Your display name must be less than 256 characters"; "onboarding_avatar_title" = "Add a profile picture"; "onboarding_avatar_message" = "You can change this anytime."; diff --git a/Riot/Assets/en.lproj/Vector.strings b/Riot/Assets/en.lproj/Vector.strings index 5f0fae942..749b7fb46 100644 --- a/Riot/Assets/en.lproj/Vector.strings +++ b/Riot/Assets/en.lproj/Vector.strings @@ -57,7 +57,6 @@ "rename" = "Rename"; "collapse" = "collapse"; "send_to" = "Send to %@"; -"sending" = "Sending"; "close" = "Close"; "skip" = "Skip"; "joined" = "Joined"; @@ -76,6 +75,11 @@ "error" = "Error"; "suggest" = "Suggest"; +// Activities +"loading" = "Loading"; +"sending" = "Sending"; +"saving" = "Saving"; + // Call Bar "callbar_only_single_active" = "Tap to return to the call (%@)"; "callbar_active_and_single_paused" = "1 active call (%@) ยท 1 paused call"; diff --git a/Riot/Generated/Strings.swift b/Riot/Generated/Strings.swift index 75d995cfa..290cf211e 100644 --- a/Riot/Generated/Strings.swift +++ b/Riot/Generated/Strings.swift @@ -2710,6 +2710,9 @@ public class VectorL10n: NSObject { /// Live location enabled public static var liveLocationSharingBannerTitle: String { return VectorL10n.tr("Vector", "live_location_sharing_banner_title") + /// Loading + public static var loading: String { + return VectorL10n.tr("Vector", "loading") } /// To discover contacts already using Matrix, %@ can send email addresses and phone numbers in your address book to your chosen Matrix identity server. Where supported, personal data is hashed before sending - please check your identity server's privacy policy for more details. public static func localContactsAccessDiscoveryWarning(_ p1: String) -> String { @@ -5731,6 +5734,10 @@ public class VectorL10n: NSObject { public static var save: String { return VectorL10n.tr("Vector", "save") } + /// Saving + public static var saving: String { + return VectorL10n.tr("Vector", "saving") + } /// Search public static var searchDefaultPlaceholder: String { return VectorL10n.tr("Vector", "search_default_placeholder") diff --git a/Riot/Generated/UntranslatedStrings.swift b/Riot/Generated/UntranslatedStrings.swift index 9370e608f..6531aaf96 100644 --- a/Riot/Generated/UntranslatedStrings.swift +++ b/Riot/Generated/UntranslatedStrings.swift @@ -42,6 +42,10 @@ public extension VectorL10n { static var onboardingDisplayNameHint: String { return VectorL10n.tr("Untranslated", "onboarding_display_name_hint") } + /// Your display name must be less than 256 characters + static var onboardingDisplayNameMaxLength: String { + return VectorL10n.tr("Untranslated", "onboarding_display_name_max_length") + } /// This will be shown when you send messages. static var onboardingDisplayNameMessage: String { return VectorL10n.tr("Untranslated", "onboarding_display_name_message") diff --git a/Riot/Modules/PhotoPicker/PhotoPickerPresenter.swift b/Riot/Modules/PhotoPicker/PhotoPickerPresenter.swift index 6a13a1ff4..87bfd2bb0 100644 --- a/Riot/Modules/PhotoPicker/PhotoPickerPresenter.swift +++ b/Riot/Modules/PhotoPicker/PhotoPickerPresenter.swift @@ -16,6 +16,7 @@ import UIKit import PhotosUI +import CommonKit @available(iOS 14.0, *) protocol PhotoPickerPresenterDelegate: AnyObject { @@ -35,6 +36,9 @@ final class PhotoPickerPresenter: NSObject { private weak var pickerViewController: UIViewController? private var filter: PHPickerFilter? + private var indicatorPresenter: UserIndicatorTypePresenterProtocol? + private var loadingIndicator: UserIndicator? + // MARK: Public weak var delegate: PhotoPickerPresenterDelegate? @@ -50,6 +54,7 @@ final class PhotoPickerPresenter: NSObject { pickerViewController.delegate = self self.pickerViewController = pickerViewController + indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: pickerViewController) presentingViewController.present(pickerViewController, animated: true, completion: nil) } @@ -58,6 +63,17 @@ final class PhotoPickerPresenter: NSObject { guard let pickerViewController = pickerViewController else { return } pickerViewController.dismiss(animated: animated, completion: completion) } + + // MARK: - Private + + func showLoadingIndicator() { + loadingIndicator = indicatorPresenter?.present(.loading(label: VectorL10n.loading, isInteractionBlocking: true)) + } + + func hideLoadingIndicator() { + loadingIndicator?.cancel() + loadingIndicator = nil + } } // MARK: - PHPickerViewControllerDelegate @@ -73,17 +89,21 @@ extension PhotoPickerPresenter: PHPickerViewControllerDelegate { return } + showLoadingIndicator() + provider.loadObject(ofClass: UIImage.self) { [weak self] image, error in guard let self = self else { return } guard let image = image as? UIImage else { DispatchQueue.main.async { + self.hideLoadingIndicator() self.delegate?.photoPickerPresenterDidCancel(self) } return } DispatchQueue.main.async { + self.hideLoadingIndicator() self.delegate?.photoPickerPresenter(self, didPickImage: image) } } diff --git a/RiotSwiftUI/Modules/Onboarding/Avatar/Coordinator/OnboardingAvatarCoordinator.swift b/RiotSwiftUI/Modules/Onboarding/Avatar/Coordinator/OnboardingAvatarCoordinator.swift index e88a80086..6bcf11694 100644 --- a/RiotSwiftUI/Modules/Onboarding/Avatar/Coordinator/OnboardingAvatarCoordinator.swift +++ b/RiotSwiftUI/Modules/Onboarding/Avatar/Coordinator/OnboardingAvatarCoordinator.swift @@ -15,7 +15,7 @@ // import SwiftUI -import MatrixSDK +import CommonKit struct OnboardingAvatarCoordinatorParameters { let userSession: UserSession @@ -32,6 +32,9 @@ final class OnboardingAvatarCoordinator: Coordinator, Presentable { private let onboardingAvatarHostingController: VectorHostingController private var onboardingAvatarViewModel: OnboardingAvatarViewModelProtocol + private var indicatorPresenter: UserIndicatorTypePresenterProtocol + private var waitingIndicator: UserIndicator? + private lazy var cameraPresenter: CameraPresenter = { let presenter = CameraPresenter() presenter.delegate = self @@ -44,6 +47,10 @@ final class OnboardingAvatarCoordinator: Coordinator, Presentable { return presenter }() + private lazy var mediaUploader: MXMediaLoader = MXMediaManager.prepareUploader(withMatrixSession: parameters.userSession.matrixSession, + initialRange: 0, + andRange: 1.0) + // MARK: Public // Must be used only internally @@ -62,6 +69,8 @@ final class OnboardingAvatarCoordinator: Coordinator, Presentable { onboardingAvatarHostingController = VectorHostingController(rootView: view) onboardingAvatarHostingController.vc_removeBackTitle() onboardingAvatarHostingController.enableNavigationBarScrollEdgesAppearance = true + + indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: onboardingAvatarHostingController) } @@ -91,6 +100,19 @@ final class OnboardingAvatarCoordinator: Coordinator, Presentable { // MARK: - Private + private func startWaiting() { + waitingIndicator = indicatorPresenter.present(.loading(label: VectorL10n.saving, isInteractionBlocking: true)) + } + + private func stopWaiting(error: Error? = nil) { + waitingIndicator?.cancel() + waitingIndicator = nil + + if let error = error { + onboardingAvatarViewModel.update(with: error) + } + } + private func pickImage() { let controller = toPresentable() photoPickerPresenter.presentPicker(from: controller, with: .images, animated: true) @@ -101,10 +123,6 @@ final class OnboardingAvatarCoordinator: Coordinator, Presentable { cameraPresenter.presentCamera(from: controller, with: [.image], animated: true) } - private lazy var mediaUploader: MXMediaLoader = MXMediaManager.prepareUploader(withMatrixSession: parameters.userSession.matrixSession, - initialRange: 0, - andRange: 1.0) - #warning("Temporary") func unknownError() -> Error { MXError(errorCode: "M.UNKNOWN", error: "Something went wrong!").createNSError() @@ -116,11 +134,11 @@ final class OnboardingAvatarCoordinator: Coordinator, Presentable { return } - onboardingAvatarViewModel.startLoading() + startWaiting() guard let avatarData = MXKTools.forceImageOrientationUp(image)?.jpegData(compressionQuality: 0.5) else { MXLog.error("[OnboardingAvatarCoordinator] Failed to create jpeg data.") - self.onboardingAvatarViewModel.stopLoading(error: self.unknownError()) + self.stopWaiting(error: self.unknownError()) return } @@ -128,20 +146,21 @@ final class OnboardingAvatarCoordinator: Coordinator, Presentable { guard let self = self else { return } guard let urlString = urlString else { - self.onboardingAvatarViewModel.stopLoading(error: self.unknownError()) + self.stopWaiting(error: self.unknownError()) return } self.parameters.userSession.account.setUserAvatarUrl(urlString) { [weak self] in guard let self = self else { return } + self.stopWaiting() self.completion?(self.parameters.userSession) } failure: { [weak self] error in guard let self = self else { return } - self.onboardingAvatarViewModel.stopLoading(error: error ?? self.unknownError()) + self.stopWaiting(error: error ?? self.unknownError()) } } failure: { [weak self] error in guard let self = self else { return } - self.onboardingAvatarViewModel.stopLoading(error: error ?? self.unknownError()) + self.stopWaiting(error: error ?? self.unknownError()) } } } diff --git a/RiotSwiftUI/Modules/Onboarding/Avatar/MockOnboardingAvatarScreenState.swift b/RiotSwiftUI/Modules/Onboarding/Avatar/MockOnboardingAvatarScreenState.swift index c151546ec..2ba7230ca 100644 --- a/RiotSwiftUI/Modules/Onboarding/Avatar/MockOnboardingAvatarScreenState.swift +++ b/RiotSwiftUI/Modules/Onboarding/Avatar/MockOnboardingAvatarScreenState.swift @@ -26,7 +26,6 @@ enum MockOnboardingAvatarScreenState: MockScreenState, CaseIterable { // mock that screen. case placeholderAvatar(userId: String, displayName: String) case userSelectedAvatar(userId: String, displayName: String, avatar: UIImage) - case waiting(userId: String, displayName: String?, avatar: UIImage) /// The associated screen var screenType: Any.Type { @@ -41,8 +40,7 @@ enum MockOnboardingAvatarScreenState: MockScreenState, CaseIterable { return [ .placeholderAvatar(userId: userId, displayName: displayName), - .userSelectedAvatar(userId: userId, displayName: displayName, avatar: avatar), - .waiting(userId: userId, displayName: displayName, avatar: avatar) + .userSelectedAvatar(userId: userId, displayName: displayName, avatar: avatar) ] } @@ -56,10 +54,6 @@ enum MockOnboardingAvatarScreenState: MockScreenState, CaseIterable { case .userSelectedAvatar(let userId, let displayName, let avatar): viewModel = OnboardingAvatarViewModel(userId: userId, displayName: displayName, avatarColorCount: avatarColorCount) viewModel.updateAvatarImage(with: avatar) - case .waiting(let userId, let displayName, let avatar): - viewModel = OnboardingAvatarViewModel(userId: userId, displayName: displayName, avatarColorCount: avatarColorCount) - viewModel.updateAvatarImage(with: avatar) - viewModel.startLoading() } return ( diff --git a/RiotSwiftUI/Modules/Onboarding/Avatar/OnboardingAvatarModels.swift b/RiotSwiftUI/Modules/Onboarding/Avatar/OnboardingAvatarModels.swift index 4237bd1b6..689aa7425 100644 --- a/RiotSwiftUI/Modules/Onboarding/Avatar/OnboardingAvatarModels.swift +++ b/RiotSwiftUI/Modules/Onboarding/Avatar/OnboardingAvatarModels.swift @@ -31,7 +31,6 @@ struct OnboardingAvatarViewState: BindableState { let placeholderAvatarLetter: String let placeholderAvatarColorIndex: Int var avatar: UIImage? - var isWaiting = false var bindings: OnboardingAvatarBindings var buttonImage: ImageAsset { diff --git a/RiotSwiftUI/Modules/Onboarding/Avatar/OnboardingAvatarViewModel.swift b/RiotSwiftUI/Modules/Onboarding/Avatar/OnboardingAvatarViewModel.swift index c9e45d0c5..f1beeb33f 100644 --- a/RiotSwiftUI/Modules/Onboarding/Avatar/OnboardingAvatarViewModel.swift +++ b/RiotSwiftUI/Modules/Onboarding/Avatar/OnboardingAvatarViewModel.swift @@ -61,13 +61,7 @@ class OnboardingAvatarViewModel: OnboardingAvatarViewModelType, OnboardingAvatar state.avatar = image } - func startLoading() { - state.isWaiting = true - } - - func stopLoading(error: Error?) { - state.isWaiting = false - + func update(with error: Error) { if let error = error as NSError? { state.bindings.alertInfo = AlertInfo(error: error) } diff --git a/RiotSwiftUI/Modules/Onboarding/Avatar/OnboardingAvatarViewModelProtocol.swift b/RiotSwiftUI/Modules/Onboarding/Avatar/OnboardingAvatarViewModelProtocol.swift index a068af94e..fa16204b6 100644 --- a/RiotSwiftUI/Modules/Onboarding/Avatar/OnboardingAvatarViewModelProtocol.swift +++ b/RiotSwiftUI/Modules/Onboarding/Avatar/OnboardingAvatarViewModelProtocol.swift @@ -24,6 +24,5 @@ protocol OnboardingAvatarViewModelProtocol { func updateAvatarImage(with image: UIImage?) - func startLoading() - func stopLoading(error: Error?) + func update(with error: Error) } diff --git a/RiotSwiftUI/Modules/Onboarding/Avatar/View/OnboardingAvatarScreen.swift b/RiotSwiftUI/Modules/Onboarding/Avatar/View/OnboardingAvatarScreen.swift index 5e8f2ace2..ea98d3be7 100644 --- a/RiotSwiftUI/Modules/Onboarding/Avatar/View/OnboardingAvatarScreen.swift +++ b/RiotSwiftUI/Modules/Onboarding/Avatar/View/OnboardingAvatarScreen.swift @@ -50,7 +50,6 @@ struct OnboardingAvatarScreen: View { } .accentColor(theme.colors.accent) .background(theme.colors.background.ignoresSafeArea()) - .waitOverlay(show: viewModel.viewState.isWaiting, allowUserInteraction: false) .alert(item: $viewModel.alertInfo) { $0.alert } } @@ -68,8 +67,8 @@ struct OnboardingAvatarScreen: View { } } .clipShape(Circle()) - .overlay(cameraButton, alignment: .bottomTrailing) .frame(width: 120, height: 120) + .overlay(cameraButton, alignment: .bottomTrailing) .onTapGesture { isPresentingPickerSelection = true } .actionSheet(isPresented: $isPresentingPickerSelection) { pickerSelectionActionSheet } } @@ -122,7 +121,7 @@ struct OnboardingAvatarScreen: View { viewModel.send(viewAction: .save) } .buttonStyle(PrimaryActionButtonStyle()) - .disabled(viewModel.viewState.avatar == nil || viewModel.viewState.isWaiting) + .disabled(viewModel.viewState.avatar == nil) Button { viewModel.send(viewAction: .skip) } label: { Text(VectorL10n.onboardingPersonalizationSkip) diff --git a/RiotSwiftUI/Modules/Onboarding/DisplayName/Coordinator/OnboardingDisplayNameCoordinator.swift b/RiotSwiftUI/Modules/Onboarding/DisplayName/Coordinator/OnboardingDisplayNameCoordinator.swift index 45c134a16..5ce3f215c 100644 --- a/RiotSwiftUI/Modules/Onboarding/DisplayName/Coordinator/OnboardingDisplayNameCoordinator.swift +++ b/RiotSwiftUI/Modules/Onboarding/DisplayName/Coordinator/OnboardingDisplayNameCoordinator.swift @@ -15,6 +15,7 @@ // import SwiftUI +import CommonKit struct OnboardingDisplayNameCoordinatorParameters { let userSession: UserSession @@ -31,6 +32,9 @@ final class OnboardingDisplayNameCoordinator: Coordinator, Presentable { private let onboardingDisplayNameHostingController: VectorHostingController private var onboardingDisplayNameViewModel: OnboardingDisplayNameViewModelProtocol + private var indicatorPresenter: UserIndicatorTypePresenterProtocol + private var waitingIndicator: UserIndicator? + // MARK: Public // Must be used only internally @@ -41,25 +45,64 @@ final class OnboardingDisplayNameCoordinator: Coordinator, Presentable { init(parameters: OnboardingDisplayNameCoordinatorParameters) { self.parameters = parameters - let viewModel = OnboardingDisplayNameViewModel.makeOnboardingDisplayNameViewModel(onboardingDisplayNameService: OnboardingDisplayNameService(userSession: parameters.userSession)) + + // Don't pre-fill the display name from the MXID to encourage the user to enter something + let viewModel = OnboardingDisplayNameViewModel() + let view = OnboardingDisplayNameScreen(viewModel: viewModel.context) onboardingDisplayNameViewModel = viewModel onboardingDisplayNameHostingController = VectorHostingController(rootView: view) onboardingDisplayNameHostingController.vc_removeBackTitle() onboardingDisplayNameHostingController.enableNavigationBarScrollEdgesAppearance = true + + indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: onboardingDisplayNameHostingController) } // MARK: - Public func start() { MXLog.debug("[OnboardingDisplayNameCoordinator] did start.") - onboardingDisplayNameViewModel.completion = { [weak self] in + onboardingDisplayNameViewModel.completion = { [weak self] result in guard let self = self else { return } MXLog.debug("[OnboardingDisplayNameCoordinator] OnboardingDisplayNameViewModel did complete.") - self.completion?(self.parameters.userSession) + + switch result { + case .save(let displayName): + self.setDisplayName(displayName) + case .skip: + self.completion?(self.parameters.userSession) + } } } func toPresentable() -> UIViewController { return self.onboardingDisplayNameHostingController } + + // MARK: - Private + + private func startWaiting() { + waitingIndicator = indicatorPresenter.present(.loading(label: VectorL10n.saving, isInteractionBlocking: true)) + } + + private func stopWaiting(error: Error? = nil) { + waitingIndicator?.cancel() + waitingIndicator = nil + + if let error = error { + onboardingDisplayNameViewModel.update(with: error) + } + } + + private func setDisplayName(_ displayName: String) { + startWaiting() + + parameters.userSession.account.setUserDisplayName(displayName) { [weak self] in + guard let self = self else { return } + self.stopWaiting() + self.completion?(self.parameters.userSession) + } failure: { [weak self] error in + guard let self = self else { return } + self.stopWaiting(error: error) + } + } } diff --git a/RiotSwiftUI/Modules/Onboarding/DisplayName/MockOnboardingDisplayNameScreenState.swift b/RiotSwiftUI/Modules/Onboarding/DisplayName/MockOnboardingDisplayNameScreenState.swift index e74fb1d01..aaf4ed81b 100644 --- a/RiotSwiftUI/Modules/Onboarding/DisplayName/MockOnboardingDisplayNameScreenState.swift +++ b/RiotSwiftUI/Modules/Onboarding/DisplayName/MockOnboardingDisplayNameScreenState.swift @@ -26,7 +26,6 @@ enum MockOnboardingDisplayNameScreenState: MockScreenState, CaseIterable { // mock that screen. case emptyTextField case filledTextField(displayName: String) - case operationInProgress(displayName: String) /// The associated screen var screenType: Any.Type { @@ -37,28 +36,24 @@ enum MockOnboardingDisplayNameScreenState: MockScreenState, CaseIterable { static var allCases: [MockOnboardingDisplayNameScreenState] { [ MockOnboardingDisplayNameScreenState.emptyTextField, - MockOnboardingDisplayNameScreenState.filledTextField(displayName: "Test User"), - MockOnboardingDisplayNameScreenState.operationInProgress(displayName: "Test User"), + MockOnboardingDisplayNameScreenState.filledTextField(displayName: "Test User") ] } /// Generate the view struct for the screen state. var screenView: ([Any], AnyView) { - let service: MockOnboardingDisplayNameService + let viewModel: OnboardingDisplayNameViewModel switch self { case .emptyTextField: - service = MockOnboardingDisplayNameService() + viewModel = OnboardingDisplayNameViewModel() case .filledTextField(let displayName): - service = MockOnboardingDisplayNameService(displayName: displayName) - case .operationInProgress(let displayName): - service = MockOnboardingDisplayNameService(displayName: displayName, isWaiting: true) + viewModel = OnboardingDisplayNameViewModel(displayName: displayName) } - let viewModel = OnboardingDisplayNameViewModel.makeOnboardingDisplayNameViewModel(onboardingDisplayNameService: service) // can simulate service and viewModel actions here if needs be. return ( - [service, viewModel], AnyView(OnboardingDisplayNameScreen(viewModel: viewModel.context)) + [self, viewModel], AnyView(OnboardingDisplayNameScreen(viewModel: viewModel.context)) ) } } diff --git a/RiotSwiftUI/Modules/Onboarding/DisplayName/OnboardingDisplayNameModels.swift b/RiotSwiftUI/Modules/Onboarding/DisplayName/OnboardingDisplayNameModels.swift index efc0ce011..8a584e379 100644 --- a/RiotSwiftUI/Modules/Onboarding/DisplayName/OnboardingDisplayNameModels.swift +++ b/RiotSwiftUI/Modules/Onboarding/DisplayName/OnboardingDisplayNameModels.swift @@ -19,14 +19,19 @@ import Foundation // MARK: View model enum OnboardingDisplayNameViewModelResult { - // Can probably be removed + case save(String) + case skip } // MARK: View struct OnboardingDisplayNameViewState: BindableState { - var isWaiting = false var bindings: OnboardingDisplayNameBindings + var validationErrorMessage: String? + + var textFieldFooterMessage: String { + validationErrorMessage ?? VectorL10n.onboardingDisplayNameHint + } } struct OnboardingDisplayNameBindings { @@ -35,6 +40,7 @@ struct OnboardingDisplayNameBindings { } enum OnboardingDisplayNameViewAction { + case validateDisplayName case save case skip } diff --git a/RiotSwiftUI/Modules/Onboarding/DisplayName/OnboardingDisplayNameViewModel.swift b/RiotSwiftUI/Modules/Onboarding/DisplayName/OnboardingDisplayNameViewModel.swift index 84ca8c431..a33016d71 100644 --- a/RiotSwiftUI/Modules/Onboarding/DisplayName/OnboardingDisplayNameViewModel.swift +++ b/RiotSwiftUI/Modules/Onboarding/DisplayName/OnboardingDisplayNameViewModel.swift @@ -28,54 +28,43 @@ class OnboardingDisplayNameViewModel: OnboardingDisplayNameViewModelType, Onboar // MARK: Private - private let onboardingDisplayNameService: OnboardingDisplayNameServiceProtocol - // MARK: Public - var completion: (() -> Void)? + var completion: ((OnboardingDisplayNameViewModelResult) -> Void)? // MARK: - Setup - - static func makeOnboardingDisplayNameViewModel(onboardingDisplayNameService: OnboardingDisplayNameServiceProtocol) -> OnboardingDisplayNameViewModelProtocol { - return OnboardingDisplayNameViewModel(onboardingDisplayNameService: onboardingDisplayNameService) - } - - private init(onboardingDisplayNameService: OnboardingDisplayNameServiceProtocol) { - self.onboardingDisplayNameService = onboardingDisplayNameService - super.init(initialViewState: Self.defaultState(onboardingDisplayNameService: onboardingDisplayNameService)) - } - - private static func defaultState(onboardingDisplayNameService: OnboardingDisplayNameServiceProtocol) -> OnboardingDisplayNameViewState { - // Start with a blank display name to encourage the user not to just use the first part of their MXID. - return OnboardingDisplayNameViewState(bindings: OnboardingDisplayNameBindings(displayName: "")) + + init(displayName: String = "") { + super.init(initialViewState: OnboardingDisplayNameViewState(bindings: OnboardingDisplayNameBindings(displayName: displayName))) } // MARK: - Public override func process(viewAction: OnboardingDisplayNameViewAction) { switch viewAction { + case .validateDisplayName: + validateDisplayName() case .save: - setDisplayName() + completion?(.save(state.bindings.displayName)) case .skip: - completion?() + completion?(.skip) + } + } + + func update(with error: Error) { + if let error = error as NSError? { + state.bindings.alertInfo = AlertInfo(error: error) } } // MARK: - Private - private func setDisplayName() { - state.isWaiting = true - - onboardingDisplayNameService.setDisplayName(context.displayName) { [weak self] result in - guard let self = self else { return } - self.state.isWaiting = false - - switch result { - case .success(_): - self.completion?() - case .failure(let error): - self.state.bindings.alertInfo = AlertInfo(error: error as NSError) - } + private func validateDisplayName() { + if state.bindings.displayName.count > 256 { + guard state.validationErrorMessage == nil else { return } + state.validationErrorMessage = VectorL10n.onboardingDisplayNameMaxLength + } else if state.validationErrorMessage != nil { + state.validationErrorMessage = nil } } } diff --git a/RiotSwiftUI/Modules/Onboarding/DisplayName/OnboardingDisplayNameViewModelProtocol.swift b/RiotSwiftUI/Modules/Onboarding/DisplayName/OnboardingDisplayNameViewModelProtocol.swift index 51a0ba241..e506ae1cd 100644 --- a/RiotSwiftUI/Modules/Onboarding/DisplayName/OnboardingDisplayNameViewModelProtocol.swift +++ b/RiotSwiftUI/Modules/Onboarding/DisplayName/OnboardingDisplayNameViewModelProtocol.swift @@ -18,9 +18,9 @@ import Foundation protocol OnboardingDisplayNameViewModelProtocol { - var completion: (() -> Void)? { get set } - @available(iOS 14, *) - static func makeOnboardingDisplayNameViewModel(onboardingDisplayNameService: OnboardingDisplayNameServiceProtocol) -> OnboardingDisplayNameViewModelProtocol + var completion: ((OnboardingDisplayNameViewModelResult) -> Void)? { get set } @available(iOS 14, *) var context: OnboardingDisplayNameViewModelType.Context { get } + + func update(with error: Error) } diff --git a/RiotSwiftUI/Modules/Onboarding/DisplayName/Service/MatrixSDK/OnboardingDisplayNameService.swift b/RiotSwiftUI/Modules/Onboarding/DisplayName/Service/MatrixSDK/OnboardingDisplayNameService.swift deleted file mode 100644 index b94ffeda8..000000000 --- a/RiotSwiftUI/Modules/Onboarding/DisplayName/Service/MatrixSDK/OnboardingDisplayNameService.swift +++ /dev/null @@ -1,52 +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 Foundation -import Combine - -@available(iOS 14.0, *) -class OnboardingDisplayNameService: OnboardingDisplayNameServiceProtocol { - - enum ServiceError: Error { - case unknown - } - - // MARK: - Properties - - // MARK: Private - - private let userSession: UserSession - - // MARK: Public - - var displayName: String? { - userSession.account.userDisplayName - } - - // MARK: - Setup - - init(userSession: UserSession) { - self.userSession = userSession - } - - func setDisplayName(_ displayName: String, completion: @escaping (Result) -> Void) { - userSession.account.setUserDisplayName(displayName) { - completion(.success(true)) - } failure: { error in - completion(.failure(error ?? ServiceError.unknown)) - } - } -} diff --git a/RiotSwiftUI/Modules/Onboarding/DisplayName/Service/Mock/MockOnboardingDisplayNameService.swift b/RiotSwiftUI/Modules/Onboarding/DisplayName/Service/Mock/MockOnboardingDisplayNameService.swift deleted file mode 100644 index 335f937a8..000000000 --- a/RiotSwiftUI/Modules/Onboarding/DisplayName/Service/Mock/MockOnboardingDisplayNameService.swift +++ /dev/null @@ -1,35 +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 Foundation -import Combine - -@available(iOS 14.0, *) -class MockOnboardingDisplayNameService: OnboardingDisplayNameServiceProtocol { - var displayName: String? - - #warning("isWaiting isn't handled.") - init(displayName: String? = nil, isWaiting: Bool = false) { - self.displayName = displayName - } - - func setDisplayName(_ displayName: String, completion: @escaping (Result) -> Void) { - DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(2)) { - self.displayName = displayName - completion(.success(true)) - } - } -} diff --git a/RiotSwiftUI/Modules/Onboarding/DisplayName/Service/OnboardingDisplayNameServiceProtocol.swift b/RiotSwiftUI/Modules/Onboarding/DisplayName/Service/OnboardingDisplayNameServiceProtocol.swift deleted file mode 100644 index 7b74fddab..000000000 --- a/RiotSwiftUI/Modules/Onboarding/DisplayName/Service/OnboardingDisplayNameServiceProtocol.swift +++ /dev/null @@ -1,27 +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 Foundation -import Combine - -@available(iOS 14.0, *) -protocol OnboardingDisplayNameServiceProtocol { - /// The user's current display name read from the `UserSession`. - var displayName: String? { get } - - /// Update the user's display name. - func setDisplayName(_ displayName: String, completion: @escaping (Result) -> Void) -} diff --git a/RiotSwiftUI/Modules/Onboarding/DisplayName/View/OnboardingDisplayNameScreen.swift b/RiotSwiftUI/Modules/Onboarding/DisplayName/View/OnboardingDisplayNameScreen.swift index 004ebc568..40537cb25 100644 --- a/RiotSwiftUI/Modules/Onboarding/DisplayName/View/OnboardingDisplayNameScreen.swift +++ b/RiotSwiftUI/Modules/Onboarding/DisplayName/View/OnboardingDisplayNameScreen.swift @@ -27,16 +27,7 @@ struct OnboardingDisplayNameScreen: View { @State private var isEditingTextField = false - #warning("Move these computed properties to the view model") - var textFieldFooterString: String { - if let errorMessage = viewModel.viewState.validationErrorMessage { - return errorMessage - } - - return VectorL10n.onboardingDisplayNameHint - } - - var textFieldFooterColor: Color { + private var textFieldFooterColor: Color { viewModel.viewState.validationErrorMessage == nil ? theme.colors.tertiaryContent : theme.colors.alert } @@ -44,6 +35,8 @@ struct OnboardingDisplayNameScreen: View { @ObservedObject var viewModel: OnboardingDisplayNameViewModel.Context + // MARK: - Views + var body: some View { ScrollView { VStack(spacing: 0) { @@ -62,7 +55,6 @@ struct OnboardingDisplayNameScreen: View { } .accentColor(theme.colors.accent) .background(theme.colors.background.ignoresSafeArea()) - .waitOverlay(show: viewModel.viewState.isWaiting, allowUserInteraction: false) .alert(item: $viewModel.alertInfo) { $0.alert } .onChange(of: viewModel.displayName) { _ in viewModel.send(viewAction: .validateDisplayName) @@ -98,7 +90,7 @@ struct OnboardingDisplayNameScreen: View { isEditing: isEditingTextField, isError: viewModel.viewState.validationErrorMessage != nil)) - Text(textFieldFooterString) + Text(viewModel.viewState.textFieldFooterMessage) .font(theme.fonts.footnote) .foregroundColor(textFieldFooterColor) .frame(maxWidth: .infinity, alignment: .leading) @@ -112,7 +104,7 @@ struct OnboardingDisplayNameScreen: View { viewModel.send(viewAction: .save) } .buttonStyle(PrimaryActionButtonStyle()) - .disabled(viewModel.displayName.isEmpty || viewModel.viewState.isWaiting) + .disabled(viewModel.displayName.isEmpty) Button { viewModel.send(viewAction: .skip) } label: { Text(VectorL10n.onboardingPersonalizationSkip) From 4cc98e8c69b6d07e385b2e8b595cd9323a78b407 Mon Sep 17 00:00:00 2001 From: Doug Date: Wed, 16 Mar 2022 16:22:16 +0000 Subject: [PATCH 31/38] Dark mode tweaks and rebase. --- .../Util/PrimaryActionButtonStyle.swift | 32 +++++++++++++------ .../OnboardingAvatarCoordinator.swift | 2 +- .../Avatar/View/OnboardingAvatarScreen.swift | 4 +++ .../OnboardingCongratulationsScreen.swift | 8 +++-- .../OnboardingDisplayNameCoordinator.swift | 2 +- .../View/OnboardingDisplayNameScreen.swift | 6 ++++ 6 files changed, 39 insertions(+), 15 deletions(-) diff --git a/RiotSwiftUI/Modules/Common/Util/PrimaryActionButtonStyle.swift b/RiotSwiftUI/Modules/Common/Util/PrimaryActionButtonStyle.swift index 5824cbc85..b2d0206fc 100644 --- a/RiotSwiftUI/Modules/Common/Util/PrimaryActionButtonStyle.swift +++ b/RiotSwiftUI/Modules/Common/Util/PrimaryActionButtonStyle.swift @@ -23,29 +23,34 @@ struct PrimaryActionButtonStyle: ButtonStyle { var customColor: Color? = nil + private var fontColor: Color { + // Always white unless disabled with a dark theme. + .white.opacity(theme.isDark && !isEnabled ? 0.3 : 1.0) + } + + private var backgroundColor: Color { + customColor ?? theme.colors.accent + } + func makeBody(configuration: Self.Configuration) -> some View { configuration.label .padding(12.0) .frame(maxWidth: .infinity) - .foregroundColor(.white) + .foregroundColor(fontColor) .font(theme.fonts.body) - .background(backgroundColor(configuration.isPressed)) - .opacity(isEnabled ? 1.0 : 0.6) + .background(backgroundColor.opacity(backgroundOpacity(when: configuration.isPressed))) .cornerRadius(8.0) } - func backgroundColor(_ isPressed: Bool) -> Color { - if let customColor = customColor { - return customColor - } - - return isPressed ? theme.colors.accent.opacity(0.6) : theme.colors.accent + func backgroundOpacity(when isPressed: Bool) -> CGFloat { + guard isEnabled else { return 0.3 } + return isPressed ? 0.6 : 1.0 } } @available(iOS 14.0, *) struct PrimaryActionButtonStyle_Previews: PreviewProvider { - static var previews: some View { + static var buttons: some View { Group { VStack { Button("Enabled") { } @@ -67,4 +72,11 @@ struct PrimaryActionButtonStyle_Previews: PreviewProvider { .padding() } } + + static var previews: some View { + buttons + .theme(.light).preferredColorScheme(.light) + buttons + .theme(.dark).preferredColorScheme(.dark) + } } diff --git a/RiotSwiftUI/Modules/Onboarding/Avatar/Coordinator/OnboardingAvatarCoordinator.swift b/RiotSwiftUI/Modules/Onboarding/Avatar/Coordinator/OnboardingAvatarCoordinator.swift index 6bcf11694..1296d0903 100644 --- a/RiotSwiftUI/Modules/Onboarding/Avatar/Coordinator/OnboardingAvatarCoordinator.swift +++ b/RiotSwiftUI/Modules/Onboarding/Avatar/Coordinator/OnboardingAvatarCoordinator.swift @@ -68,7 +68,7 @@ final class OnboardingAvatarCoordinator: Coordinator, Presentable { onboardingAvatarViewModel = viewModel onboardingAvatarHostingController = VectorHostingController(rootView: view) onboardingAvatarHostingController.vc_removeBackTitle() - onboardingAvatarHostingController.enableNavigationBarScrollEdgesAppearance = true + onboardingAvatarHostingController.enableNavigationBarScrollEdgeAppearance = true indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: onboardingAvatarHostingController) } diff --git a/RiotSwiftUI/Modules/Onboarding/Avatar/View/OnboardingAvatarScreen.swift b/RiotSwiftUI/Modules/Onboarding/Avatar/View/OnboardingAvatarScreen.swift index ea98d3be7..2fab3fa66 100644 --- a/RiotSwiftUI/Modules/Onboarding/Avatar/View/OnboardingAvatarScreen.swift +++ b/RiotSwiftUI/Modules/Onboarding/Avatar/View/OnboardingAvatarScreen.swift @@ -140,5 +140,9 @@ struct OnboardingAvatar_Previews: PreviewProvider { static var previews: some View { stateRenderer.screenGroup(addNavigation: true) .navigationViewStyle(.stack) + .theme(.light).preferredColorScheme(.light) + stateRenderer.screenGroup(addNavigation: true) + .navigationViewStyle(.stack) + .theme(.dark).preferredColorScheme(.dark) } } diff --git a/RiotSwiftUI/Modules/Onboarding/Congratulations/View/OnboardingCongratulationsScreen.swift b/RiotSwiftUI/Modules/Onboarding/Congratulations/View/OnboardingCongratulationsScreen.swift index 85cb13571..a0614db66 100644 --- a/RiotSwiftUI/Modules/Onboarding/Congratulations/View/OnboardingCongratulationsScreen.swift +++ b/RiotSwiftUI/Modules/Onboarding/Congratulations/View/OnboardingCongratulationsScreen.swift @@ -62,8 +62,10 @@ struct OnboardingCongratulationsScreen: View { /// The main content of the view to be shown in a scroll view. var mainContent: some View { - VStack(spacing: 62) { + VStack(spacing: 42) { Image(Asset.Images.onboardingCongratulationsIcon.name) + .resizable() + .frame(width: 90, height: 90) .accessibilityHidden(true) VStack(spacing: 8) { @@ -93,7 +95,7 @@ struct OnboardingCongratulationsScreen: View { VStack(spacing: 12) { Button { viewModel.send(viewAction: .personaliseProfile) } label: { Text(VectorL10n.onboardingCongratulationsPersonalizeButton) - .font(theme.fonts.bodySB) + .font(theme.fonts.body) .foregroundColor(theme.colors.accent) } .buttonStyle(PrimaryActionButtonStyle(customColor: .white)) @@ -110,7 +112,7 @@ struct OnboardingCongratulationsScreen: View { var homeButton: some View { Button { viewModel.send(viewAction: .takeMeHome) } label: { Text(VectorL10n.onboardingCongratulationsHomeButton) - .font(theme.fonts.bodySB) + .font(theme.fonts.body) .foregroundColor(theme.colors.accent) } .buttonStyle(PrimaryActionButtonStyle(customColor: .white)) diff --git a/RiotSwiftUI/Modules/Onboarding/DisplayName/Coordinator/OnboardingDisplayNameCoordinator.swift b/RiotSwiftUI/Modules/Onboarding/DisplayName/Coordinator/OnboardingDisplayNameCoordinator.swift index 5ce3f215c..94f992162 100644 --- a/RiotSwiftUI/Modules/Onboarding/DisplayName/Coordinator/OnboardingDisplayNameCoordinator.swift +++ b/RiotSwiftUI/Modules/Onboarding/DisplayName/Coordinator/OnboardingDisplayNameCoordinator.swift @@ -53,7 +53,7 @@ final class OnboardingDisplayNameCoordinator: Coordinator, Presentable { onboardingDisplayNameViewModel = viewModel onboardingDisplayNameHostingController = VectorHostingController(rootView: view) onboardingDisplayNameHostingController.vc_removeBackTitle() - onboardingDisplayNameHostingController.enableNavigationBarScrollEdgesAppearance = true + onboardingDisplayNameHostingController.enableNavigationBarScrollEdgeAppearance = true indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: onboardingDisplayNameHostingController) } diff --git a/RiotSwiftUI/Modules/Onboarding/DisplayName/View/OnboardingDisplayNameScreen.swift b/RiotSwiftUI/Modules/Onboarding/DisplayName/View/OnboardingDisplayNameScreen.swift index 40537cb25..54a21e263 100644 --- a/RiotSwiftUI/Modules/Onboarding/DisplayName/View/OnboardingDisplayNameScreen.swift +++ b/RiotSwiftUI/Modules/Onboarding/DisplayName/View/OnboardingDisplayNameScreen.swift @@ -67,6 +67,7 @@ struct OnboardingDisplayNameScreen: View { Image(Asset.Images.onboardingCongratulationsIcon.name) .renderingMode(.template) .foregroundColor(theme.colors.accent) + .background(Circle().foregroundColor(.white).padding(2)) .padding(.bottom, 8) .accessibilityHidden(true) @@ -122,5 +123,10 @@ struct OnboardingDisplayName_Previews: PreviewProvider { static let stateRenderer = MockOnboardingDisplayNameScreenState.stateRenderer static var previews: some View { stateRenderer.screenGroup(addNavigation: true) + .navigationViewStyle(.stack) + .theme(.light).preferredColorScheme(.light) + stateRenderer.screenGroup(addNavigation: true) + .navigationViewStyle(.stack) + .theme(.dark).preferredColorScheme(.dark) } } From 61ed2a28d8cfcaea1be4a96fe10a74542e1a1b1a Mon Sep 17 00:00:00 2001 From: Doug Date: Thu, 17 Mar 2022 11:50:59 +0000 Subject: [PATCH 32/38] Improve layout on different devices Fix dynamic type on some fonts. --- DesignKit/Variants/Fonts/ElementFonts.swift | 36 +++++++++---------- .../View/AnalyticsPrompt.swift | 6 +++- .../Avatar/View/OnboardingAvatarScreen.swift | 7 ++-- .../View/OnboardingDisplayNameScreen.swift | 9 +++-- 4 files changed, 35 insertions(+), 23 deletions(-) diff --git a/DesignKit/Variants/Fonts/ElementFonts.swift b/DesignKit/Variants/Fonts/ElementFonts.swift index d7538c905..e17984389 100644 --- a/DesignKit/Variants/Fonts/ElementFonts.swift +++ b/DesignKit/Variants/Fonts/ElementFonts.swift @@ -110,10 +110,10 @@ extension ElementFonts: Fonts { public var title2: SharedFont { let uiFont = self.font(forTextStyle: .title2) - if #available(iOS 13.0, *) { - return SharedFont(uiFont: uiFont, font: Font(uiFont)) - } else if #available(iOS 14.0, *) { + if #available(iOS 14.0, *) { return SharedFont(uiFont: uiFont, font: .title2) + } else if #available(iOS 13.0, *) { + return SharedFont(uiFont: uiFont, font: Font(uiFont)) } else { return SharedFont(uiFont: uiFont) } @@ -122,10 +122,10 @@ extension ElementFonts: Fonts { public var title2B: SharedFont { let uiFont = self.title2.uiFont.vc_bold - if #available(iOS 13.0, *) { - return SharedFont(uiFont: uiFont, font: Font(uiFont)) - } else if #available(iOS 14.0, *) { + if #available(iOS 14.0, *) { return SharedFont(uiFont: uiFont, font: .title2.bold()) + } else if #available(iOS 13.0, *) { + return SharedFont(uiFont: uiFont, font: Font(uiFont)) } else { return SharedFont(uiFont: uiFont) } @@ -134,10 +134,10 @@ extension ElementFonts: Fonts { public var title3: SharedFont { let uiFont = self.font(forTextStyle: .title3) - if #available(iOS 13.0, *) { - return SharedFont(uiFont: uiFont, font: Font(uiFont)) - } else if #available(iOS 14.0, *) { + if #available(iOS 14.0, *) { return SharedFont(uiFont: uiFont, font: .title3) + } else if #available(iOS 13.0, *) { + return SharedFont(uiFont: uiFont, font: Font(uiFont)) } else { return SharedFont(uiFont: uiFont) } @@ -146,10 +146,10 @@ extension ElementFonts: Fonts { public var title3SB: SharedFont { let uiFont = self.title3.uiFont.vc_semiBold - if #available(iOS 13.0, *) { - return SharedFont(uiFont: uiFont, font: Font(uiFont)) - } else if #available(iOS 14.0, *) { + if #available(iOS 14.0, *) { return SharedFont(uiFont: uiFont, font: .title3.weight(.semibold)) + } else if #available(iOS 13.0, *) { + return SharedFont(uiFont: uiFont, font: Font(uiFont)) } else { return SharedFont(uiFont: uiFont) } @@ -258,10 +258,10 @@ extension ElementFonts: Fonts { public var caption2: SharedFont { let uiFont = self.font(forTextStyle: .caption2) - if #available(iOS 13.0, *) { - return SharedFont(uiFont: uiFont, font: Font(uiFont)) - } else if #available(iOS 14.0, *) { + if #available(iOS 14.0, *) { return SharedFont(uiFont: uiFont, font: .caption2) + } else if #available(iOS 13.0, *) { + return SharedFont(uiFont: uiFont, font: Font(uiFont)) } else { return SharedFont(uiFont: uiFont) } @@ -270,10 +270,10 @@ extension ElementFonts: Fonts { public var caption2SB: SharedFont { let uiFont = self.caption2.uiFont.vc_semiBold - if #available(iOS 13.0, *) { - return SharedFont(uiFont: uiFont, font: Font(uiFont)) - } else if #available(iOS 14.0, *) { + if #available(iOS 14.0, *) { return SharedFont(uiFont: uiFont, font: .caption2.weight(.semibold)) + } else if #available(iOS 13.0, *) { + return SharedFont(uiFont: uiFont, font: Font(uiFont)) } else { return SharedFont(uiFont: uiFont) } diff --git a/RiotSwiftUI/Modules/AnalyticsPrompt/View/AnalyticsPrompt.swift b/RiotSwiftUI/Modules/AnalyticsPrompt/View/AnalyticsPrompt.swift index 816498006..96c917259 100644 --- a/RiotSwiftUI/Modules/AnalyticsPrompt/View/AnalyticsPrompt.swift +++ b/RiotSwiftUI/Modules/AnalyticsPrompt/View/AnalyticsPrompt.swift @@ -71,13 +71,14 @@ struct AnalyticsPrompt: View { Text(VectorL10n.analyticsPromptTitle(AppInfo.current.displayName)) .font(theme.fonts.title2B) + .multilineTextAlignment(.center) .foregroundColor(theme.colors.primaryContent) .padding(.bottom, 2) messageText .font(theme.fonts.body) - .foregroundColor(theme.colors.secondaryContent) .multilineTextAlignment(.center) + .foregroundColor(theme.colors.secondaryContent) Divider() .background(theme.colors.quinaryContent) @@ -117,8 +118,11 @@ struct AnalyticsPrompt: View { .padding(.top, 50) .padding(.horizontal, horizontalPadding) } + .frame(maxWidth: OnboardingConstants.maxContentWidth) + .frame(maxWidth: .infinity) buttons + .frame(maxWidth: OnboardingConstants.maxContentWidth) .padding(.horizontal, horizontalPadding) .padding(.bottom, geometry.safeAreaInsets.bottom > 0 ? 0 : 16) } diff --git a/RiotSwiftUI/Modules/Onboarding/Avatar/View/OnboardingAvatarScreen.swift b/RiotSwiftUI/Modules/Onboarding/Avatar/View/OnboardingAvatarScreen.swift index 2fab3fa66..fa5fe234e 100644 --- a/RiotSwiftUI/Modules/Onboarding/Avatar/View/OnboardingAvatarScreen.swift +++ b/RiotSwiftUI/Modules/Onboarding/Avatar/View/OnboardingAvatarScreen.swift @@ -46,8 +46,9 @@ struct OnboardingAvatarScreen: View { } .padding(.horizontal) .padding(.top, 8) - .frame(maxHeight: .infinity) + .frame(maxWidth: OnboardingConstants.maxContentWidth) } + .frame(maxWidth: .infinity, maxHeight: .infinity) .accentColor(theme.colors.accent) .background(theme.colors.background.ignoresSafeArea()) .alert(item: $viewModel.alertInfo) { $0.alert } @@ -106,10 +107,12 @@ struct OnboardingAvatarScreen: View { VStack(spacing: 8) { Text(VectorL10n.onboardingAvatarTitle) .font(theme.fonts.title2B) + .multilineTextAlignment(.center) .foregroundColor(theme.colors.primaryContent) Text(VectorL10n.onboardingAvatarMessage) .font(theme.fonts.subheadline) + .multilineTextAlignment(.center) .foregroundColor(theme.colors.secondaryContent) } } @@ -134,7 +137,7 @@ struct OnboardingAvatarScreen: View { // MARK: - Previews -@available(iOS 14.0, *) +@available(iOS 15.0, *) struct OnboardingAvatar_Previews: PreviewProvider { static let stateRenderer = MockOnboardingAvatarScreenState.stateRenderer static var previews: some View { diff --git a/RiotSwiftUI/Modules/Onboarding/DisplayName/View/OnboardingDisplayNameScreen.swift b/RiotSwiftUI/Modules/Onboarding/DisplayName/View/OnboardingDisplayNameScreen.swift index 54a21e263..5891de1d1 100644 --- a/RiotSwiftUI/Modules/Onboarding/DisplayName/View/OnboardingDisplayNameScreen.swift +++ b/RiotSwiftUI/Modules/Onboarding/DisplayName/View/OnboardingDisplayNameScreen.swift @@ -51,8 +51,9 @@ struct OnboardingDisplayNameScreen: View { } .padding(.horizontal) .padding(.top, 8) - .frame(maxHeight: .infinity) + .frame(maxWidth: OnboardingConstants.maxContentWidth) } + .frame(maxWidth: .infinity, maxHeight: .infinity) .accentColor(theme.colors.accent) .background(theme.colors.background.ignoresSafeArea()) .alert(item: $viewModel.alertInfo) { $0.alert } @@ -65,18 +66,22 @@ struct OnboardingDisplayNameScreen: View { var header: some View { VStack(spacing: 8) { Image(Asset.Images.onboardingCongratulationsIcon.name) + .resizable() .renderingMode(.template) .foregroundColor(theme.colors.accent) + .frame(width: 90, height: 90) .background(Circle().foregroundColor(.white).padding(2)) .padding(.bottom, 8) .accessibilityHidden(true) Text(VectorL10n.onboardingDisplayNameTitle) .font(theme.fonts.title2B) + .multilineTextAlignment(.center) .foregroundColor(theme.colors.primaryContent) Text(VectorL10n.onboardingDisplayNameMessage) .font(theme.fonts.subheadline) + .multilineTextAlignment(.center) .foregroundColor(theme.colors.secondaryContent) } } @@ -118,7 +123,7 @@ struct OnboardingDisplayNameScreen: View { // MARK: - Previews -@available(iOS 14.0, *) +@available(iOS 15.0, *) struct OnboardingDisplayName_Previews: PreviewProvider { static let stateRenderer = MockOnboardingDisplayNameScreenState.stateRenderer static var previews: some View { From 4b75390b7b53ab30bed8013e742dc00ecc24fa40 Mon Sep 17 00:00:00 2001 From: Doug Date: Thu, 17 Mar 2022 16:19:13 +0000 Subject: [PATCH 33/38] Add tests to onboarding personalisation. --- .../Modules/Common/Mock/MockAppScreens.swift | 1 + .../MockOnboardingAvatarScreenState.swift | 22 +++++-- .../Test/UI/OnboardingAvatarUITests.swift | 39 ++++++++----- .../Unit/OnboardingAvatarViewModelTests.swift | 42 +++++++------- .../Avatar/View/OnboardingAvatarScreen.swift | 2 + ...OnboardingCongratulationsScreenState.swift | 4 +- .../UI/OnboardingCongratulationsUITests.swift | 24 ++++++-- .../OnboardingCongratulationsScreen.swift | 3 + ...MockOnboardingDisplayNameScreenState.swift | 8 ++- .../OnboardingDisplayNameViewModel.swift | 1 + .../UI/OnboardingDisplayNameUITests.swift | 48 +++++++++++----- .../OnboardingDisplayNameViewModelTests.swift | 57 +++++++++++-------- .../View/OnboardingDisplayNameScreen.swift | 1 + 13 files changed, 167 insertions(+), 85 deletions(-) diff --git a/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift b/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift index 709a06325..7201176c3 100644 --- a/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift +++ b/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift @@ -20,6 +20,7 @@ import Foundation @available(iOS 14.0, *) enum MockAppScreens { static let appScreens: [MockScreenState.Type] = [ + MockOnboardingAvatarScreenState.self, MockOnboardingDisplayNameScreenState.self, MockOnboardingCongratulationsScreenState.self, MockOnboardingUseCaseSelectionScreenState.self, diff --git a/RiotSwiftUI/Modules/Onboarding/Avatar/MockOnboardingAvatarScreenState.swift b/RiotSwiftUI/Modules/Onboarding/Avatar/MockOnboardingAvatarScreenState.swift index 2ba7230ca..8dff96b67 100644 --- a/RiotSwiftUI/Modules/Onboarding/Avatar/MockOnboardingAvatarScreenState.swift +++ b/RiotSwiftUI/Modules/Onboarding/Avatar/MockOnboardingAvatarScreenState.swift @@ -25,7 +25,7 @@ enum MockOnboardingAvatarScreenState: MockScreenState, CaseIterable { // with specific, minimal associated data that will allow you // mock that screen. case placeholderAvatar(userId: String, displayName: String) - case userSelectedAvatar(userId: String, displayName: String, avatar: UIImage) + case userSelectedAvatar(userId: String, displayName: String) /// The associated screen var screenType: Any.Type { @@ -36,11 +36,10 @@ enum MockOnboardingAvatarScreenState: MockScreenState, CaseIterable { static var allCases: [MockOnboardingAvatarScreenState] { let userId = "@example:matrix.org" let displayName = "Jane" - let avatar = Asset.Images.appSymbol.image return [ .placeholderAvatar(userId: userId, displayName: displayName), - .userSelectedAvatar(userId: userId, displayName: displayName, avatar: avatar) + .userSelectedAvatar(userId: userId, displayName: displayName) ] } @@ -51,9 +50,9 @@ enum MockOnboardingAvatarScreenState: MockScreenState, CaseIterable { switch self { case .placeholderAvatar(let userId, let displayName): viewModel = OnboardingAvatarViewModel(userId: userId, displayName: displayName, avatarColorCount: avatarColorCount) - case .userSelectedAvatar(let userId, let displayName, let avatar): + case .userSelectedAvatar(let userId, let displayName): viewModel = OnboardingAvatarViewModel(userId: userId, displayName: displayName, avatarColorCount: avatarColorCount) - viewModel.updateAvatarImage(with: avatar) + viewModel.updateAvatarImage(with: Asset.Images.appSymbol.image) } return ( @@ -63,3 +62,16 @@ enum MockOnboardingAvatarScreenState: MockScreenState, CaseIterable { ) } } + +@available(iOS 14.0, *) +extension MockOnboardingAvatarScreenState: CustomStringConvertible { + // Added to have different descriptions in the SwiftUI target's list. + var description: String { + switch self { + case .placeholderAvatar: + return "placeholderAvatar" + case .userSelectedAvatar: + return "userSelectedAvatar" + } + } +} diff --git a/RiotSwiftUI/Modules/Onboarding/Avatar/Test/UI/OnboardingAvatarUITests.swift b/RiotSwiftUI/Modules/Onboarding/Avatar/Test/UI/OnboardingAvatarUITests.swift index 0c356f221..a77e77b39 100644 --- a/RiotSwiftUI/Modules/Onboarding/Avatar/Test/UI/OnboardingAvatarUITests.swift +++ b/RiotSwiftUI/Modules/Onboarding/Avatar/Test/UI/OnboardingAvatarUITests.swift @@ -31,23 +31,32 @@ class OnboardingAvatarUITests: MockScreenTest { func verifyOnboardingAvatarScreen() throws { guard let screenState = screenState as? MockOnboardingAvatarScreenState else { fatalError("no screen") } switch screenState { - case .presence(let presence): - verifyOnboardingAvatarPresence(presence: presence) - case .longDisplayName(let name): - verifyOnboardingAvatarLongName(name: name) + case .placeholderAvatar(let userId, let displayName): + verifyPlaceholderAvatar(userId: userId, displayName: displayName) + case .userSelectedAvatar: + verifyUserSelectedAvatar() } } - - func verifyOnboardingAvatarPresence(presence: OnboardingAvatarPresence) { - let presenceText = app.staticTexts["presenceText"] - XCTAssert(presenceText.exists) - XCTAssertEqual(presenceText.label, presence.title) + + func verifyPlaceholderAvatar(userId: String, displayName: String) { + guard let firstLetter = displayName.uppercased().first else { + XCTFail("Unable to get the first letter of the display name.") + return + } + + let placeholderAvatar = app.staticTexts["placeholderAvatar"] + XCTAssertTrue(placeholderAvatar.exists, "The placeholder avatar should be shown.") + XCTAssertEqual(placeholderAvatar.label, String(firstLetter), "The placeholder avatar should show the first letter of the display name.") + + let avatarImage = app.images["avatarImage"] + XCTAssertFalse(avatarImage.exists, "The avatar image should be hidden as no selection has been made.") } - - func verifyOnboardingAvatarLongName(name: String) { - let displayNameText = app.staticTexts["displayNameText"] - XCTAssert(displayNameText.exists) - XCTAssertEqual(displayNameText.label, name) + + func verifyUserSelectedAvatar() { + let placeholderAvatar = app.otherElements["placeholderAvatar"] + XCTAssertFalse(placeholderAvatar.exists, "The placeholder avatar should be hidden.") + + let avatarImage = app.images["avatarImage"] + XCTAssertTrue(avatarImage.exists, "The selected avatar should be shown.") } - } diff --git a/RiotSwiftUI/Modules/Onboarding/Avatar/Test/Unit/OnboardingAvatarViewModelTests.swift b/RiotSwiftUI/Modules/Onboarding/Avatar/Test/Unit/OnboardingAvatarViewModelTests.swift index 92783d020..a65df871e 100644 --- a/RiotSwiftUI/Modules/Onboarding/Avatar/Test/Unit/OnboardingAvatarViewModelTests.swift +++ b/RiotSwiftUI/Modules/Onboarding/Avatar/Test/Unit/OnboardingAvatarViewModelTests.swift @@ -22,36 +22,36 @@ import Combine @available(iOS 14.0, *) class OnboardingAvatarViewModelTests: XCTestCase { private enum Constants { - static let presenceInitialValue: OnboardingAvatarPresence = .offline + static let userId = "@user:matrix.org" static let displayName = "Alice" + static let avatarColorCount = DefaultThemeSwiftUI().colors.namesAndAvatars.count + static let avatarImage = Asset.Images.appSymbol.image } - var service: MockOnboardingAvatarService! + var viewModel: OnboardingAvatarViewModelProtocol! var context: OnboardingAvatarViewModelType.Context! - var cancellables = Set() + override func setUpWithError() throws { - service = MockOnboardingAvatarService(displayName: Constants.displayName, presence: Constants.presenceInitialValue) - viewModel = OnboardingAvatarViewModel.makeOnboardingAvatarViewModel(onboardingAvatarService: service) + viewModel = OnboardingAvatarViewModel(userId: Constants.userId, + displayName: Constants.displayName, + avatarColorCount: Constants.avatarColorCount) context = viewModel.context } func testInitialState() { - XCTAssertEqual(context.viewState.displayName, Constants.displayName) - XCTAssertEqual(context.viewState.presence, Constants.presenceInitialValue) + XCTAssertEqual(context.viewState.placeholderAvatarLetter, "A") + XCTAssertNil(context.viewState.avatar) + XCTAssertNil(context.viewState.bindings.alertInfo) } - - 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: OnboardingAvatarPresence = .online - let newPresenceValue2: OnboardingAvatarPresence = .idle - service.simulateUpdate(presence: newPresenceValue1) - service.simulateUpdate(presence: newPresenceValue2) - XCTAssertEqual(try awaitDeferred(), [Constants.presenceInitialValue, newPresenceValue1, newPresenceValue2]) + + func testUpdatingAvatar() { + // Given the default view model + XCTAssertNil(context.viewState.avatar, "The default view state should not have an avatar.") + + // When updating the image + viewModel.updateAvatarImage(with: Constants.avatarImage) + + // Then the view state should contain the new image + XCTAssertEqual(context.viewState.avatar, Constants.avatarImage, "The view state should contain the new avatar image.") } } diff --git a/RiotSwiftUI/Modules/Onboarding/Avatar/View/OnboardingAvatarScreen.swift b/RiotSwiftUI/Modules/Onboarding/Avatar/View/OnboardingAvatarScreen.swift index fa5fe234e..8ef8cb9fd 100644 --- a/RiotSwiftUI/Modules/Onboarding/Avatar/View/OnboardingAvatarScreen.swift +++ b/RiotSwiftUI/Modules/Onboarding/Avatar/View/OnboardingAvatarScreen.swift @@ -62,9 +62,11 @@ struct OnboardingAvatarScreen: View { Image(uiImage: avatarImage) .resizable() .scaledToFill() + .accessibilityIdentifier("avatarImage") } else { PlaceholderAvatarImage(firstCharacter: viewModel.viewState.placeholderAvatarLetter, colorIndex: viewModel.viewState.placeholderAvatarColorIndex) + .accessibilityIdentifier("placeholderAvatar") } } .clipShape(Circle()) diff --git a/RiotSwiftUI/Modules/Onboarding/Congratulations/MockOnboardingCongratulationsScreenState.swift b/RiotSwiftUI/Modules/Onboarding/Congratulations/MockOnboardingCongratulationsScreenState.swift index 5d114dca1..f2b1773bc 100644 --- a/RiotSwiftUI/Modules/Onboarding/Congratulations/MockOnboardingCongratulationsScreenState.swift +++ b/RiotSwiftUI/Modules/Onboarding/Congratulations/MockOnboardingCongratulationsScreenState.swift @@ -25,7 +25,7 @@ enum MockOnboardingCongratulationsScreenState: MockScreenState, CaseIterable { // with specific, minimal associated data that will allow you // mock that screen. case regular - case personalisationDisabled + case personalizationDisabled /// The associated screen var screenType: Any.Type { @@ -39,7 +39,7 @@ enum MockOnboardingCongratulationsScreenState: MockScreenState, CaseIterable { switch self { case .regular: viewModel = OnboardingCongratulationsViewModel(userId: "@testuser:example.com") - case .personalisationDisabled: + case .personalizationDisabled: viewModel = OnboardingCongratulationsViewModel(userId: "@testuser:example.com", personalizationDisabled: true) } diff --git a/RiotSwiftUI/Modules/Onboarding/Congratulations/Test/UI/OnboardingCongratulationsUITests.swift b/RiotSwiftUI/Modules/Onboarding/Congratulations/Test/UI/OnboardingCongratulationsUITests.swift index 5b46e5c97..f82b46245 100644 --- a/RiotSwiftUI/Modules/Onboarding/Congratulations/Test/UI/OnboardingCongratulationsUITests.swift +++ b/RiotSwiftUI/Modules/Onboarding/Congratulations/Test/UI/OnboardingCongratulationsUITests.swift @@ -31,10 +31,26 @@ class OnboardingCongratulationsUITests: MockScreenTest { func verifyOnboardingCongratulationsScreen() throws { guard let screenState = screenState as? MockOnboardingCongratulationsScreenState else { fatalError("no screen") } switch screenState { - case .congratulations: - // There isn't anything to test here - break + case .regular: + verifyButtons() + case .personalizationDisabled: + verifyButtonsWhenPersonalizationIsDisabled() } } - + + func verifyButtons() { + let personalizeButton = app.buttons["personalizeButton"] + XCTAssertTrue(personalizeButton.exists, "The personalization button should be shown.") + + let homeButton = app.buttons["homeButton"] + XCTAssertTrue(homeButton.exists, "The home button should always be shown.") + } + + func verifyButtonsWhenPersonalizationIsDisabled() { + let personalizeButton = app.buttons["personalizeButton"] + XCTAssertFalse(personalizeButton.exists, "The personalization button should be hidden.") + + let homeButton = app.buttons["homeButton"] + XCTAssertTrue(homeButton.exists, "The home button should always be shown.") + } } diff --git a/RiotSwiftUI/Modules/Onboarding/Congratulations/View/OnboardingCongratulationsScreen.swift b/RiotSwiftUI/Modules/Onboarding/Congratulations/View/OnboardingCongratulationsScreen.swift index a0614db66..244eba3a1 100644 --- a/RiotSwiftUI/Modules/Onboarding/Congratulations/View/OnboardingCongratulationsScreen.swift +++ b/RiotSwiftUI/Modules/Onboarding/Congratulations/View/OnboardingCongratulationsScreen.swift @@ -99,12 +99,14 @@ struct OnboardingCongratulationsScreen: View { .foregroundColor(theme.colors.accent) } .buttonStyle(PrimaryActionButtonStyle(customColor: .white)) + .accessibilityIdentifier("personalizeButton") Button { viewModel.send(viewAction: .takeMeHome) } label: { Text(VectorL10n.onboardingCongratulationsHomeButton) .font(theme.fonts.body) .padding(.vertical, 12) } + .accessibilityIdentifier("homeButton") } } @@ -116,6 +118,7 @@ struct OnboardingCongratulationsScreen: View { .foregroundColor(theme.colors.accent) } .buttonStyle(PrimaryActionButtonStyle(customColor: .white)) + .accessibilityIdentifier("homeButton") } } diff --git a/RiotSwiftUI/Modules/Onboarding/DisplayName/MockOnboardingDisplayNameScreenState.swift b/RiotSwiftUI/Modules/Onboarding/DisplayName/MockOnboardingDisplayNameScreenState.swift index aaf4ed81b..797908a9e 100644 --- a/RiotSwiftUI/Modules/Onboarding/DisplayName/MockOnboardingDisplayNameScreenState.swift +++ b/RiotSwiftUI/Modules/Onboarding/DisplayName/MockOnboardingDisplayNameScreenState.swift @@ -26,6 +26,7 @@ enum MockOnboardingDisplayNameScreenState: MockScreenState, CaseIterable { // mock that screen. case emptyTextField case filledTextField(displayName: String) + case longDisplayName(displayName: String) /// The associated screen var screenType: Any.Type { @@ -36,7 +37,10 @@ enum MockOnboardingDisplayNameScreenState: MockScreenState, CaseIterable { static var allCases: [MockOnboardingDisplayNameScreenState] { [ MockOnboardingDisplayNameScreenState.emptyTextField, - MockOnboardingDisplayNameScreenState.filledTextField(displayName: "Test User") + MockOnboardingDisplayNameScreenState.filledTextField(displayName: "Test User"), + MockOnboardingDisplayNameScreenState.longDisplayName(displayName: """ + Bacon ipsum dolor amet filet mignon chicken kevin andouille. Doner shoulder beef, brisket bresaola turkey jowl venison. Ham hock cow turducken, chislic venison doner short loin strip steak tri-tip jowl. Sirloin pork belly hamburger ribeye. Tail capicola alcatra short ribs turkey doner. + """) ] } @@ -46,7 +50,7 @@ enum MockOnboardingDisplayNameScreenState: MockScreenState, CaseIterable { switch self { case .emptyTextField: viewModel = OnboardingDisplayNameViewModel() - case .filledTextField(let displayName): + case .filledTextField(let displayName), .longDisplayName(displayName: let displayName): viewModel = OnboardingDisplayNameViewModel(displayName: displayName) } diff --git a/RiotSwiftUI/Modules/Onboarding/DisplayName/OnboardingDisplayNameViewModel.swift b/RiotSwiftUI/Modules/Onboarding/DisplayName/OnboardingDisplayNameViewModel.swift index a33016d71..e701cce08 100644 --- a/RiotSwiftUI/Modules/Onboarding/DisplayName/OnboardingDisplayNameViewModel.swift +++ b/RiotSwiftUI/Modules/Onboarding/DisplayName/OnboardingDisplayNameViewModel.swift @@ -36,6 +36,7 @@ class OnboardingDisplayNameViewModel: OnboardingDisplayNameViewModelType, Onboar init(displayName: String = "") { super.init(initialViewState: OnboardingDisplayNameViewState(bindings: OnboardingDisplayNameBindings(displayName: displayName))) + validateDisplayName() } // MARK: - Public diff --git a/RiotSwiftUI/Modules/Onboarding/DisplayName/Test/UI/OnboardingDisplayNameUITests.swift b/RiotSwiftUI/Modules/Onboarding/DisplayName/Test/UI/OnboardingDisplayNameUITests.swift index b8062c228..7ab52e933 100644 --- a/RiotSwiftUI/Modules/Onboarding/DisplayName/Test/UI/OnboardingDisplayNameUITests.swift +++ b/RiotSwiftUI/Modules/Onboarding/DisplayName/Test/UI/OnboardingDisplayNameUITests.swift @@ -31,23 +31,45 @@ class OnboardingDisplayNameUITests: MockScreenTest { func verifyOnboardingDisplayNameScreen() throws { guard let screenState = screenState as? MockOnboardingDisplayNameScreenState else { fatalError("no screen") } switch screenState { - case .presence(let presence): - verifyOnboardingDisplayNamePresence(presence: presence) - case .longDisplayName(let name): - verifyOnboardingDisplayNameLongName(name: name) + case .emptyTextField: + verifyEmptyTextField() + case .filledTextField(let displayName): + verifyDisplayName(displayName: displayName) + case .longDisplayName(displayName: let displayName): + verifyLongDisplayName(displayName: displayName) } } - func verifyOnboardingDisplayNamePresence(presence: OnboardingDisplayNamePresence) { - let presenceText = app.staticTexts["presenceText"] - XCTAssert(presenceText.exists) - XCTAssertEqual(presenceText.label, presence.title) + func verifyEmptyTextField() { + let textField = app.textFields.element + XCTAssertTrue(textField.exists, "The textfield should always be shown.") + XCTAssertEqual(textField.value as? String, VectorL10n.onboardingDisplayNamePlaceholder, "When the textfield is empty, the value should match the placeholder.") + XCTAssertEqual(textField.placeholderValue, VectorL10n.onboardingDisplayNamePlaceholder, "The textfield's placeholder should be set.") + + let footer = app.staticTexts["textFieldFooter"] + XCTAssertTrue(footer.exists, "The textfield's footer should always be shown.") + XCTAssertEqual(footer.label, VectorL10n.onboardingDisplayNameHint, "The footer should display a hint when no text is set.") } - func verifyOnboardingDisplayNameLongName(name: String) { - let displayNameText = app.staticTexts["displayNameText"] - XCTAssert(displayNameText.exists) - XCTAssertEqual(displayNameText.label, name) + func verifyDisplayName(displayName: String) { + let textField = app.textFields.element + XCTAssertTrue(textField.exists, "The textfield should always be shown.") + XCTAssertEqual(textField.value as? String, displayName, "When a name has been set, it should show in the textfield.") + XCTAssertEqual(textField.placeholderValue, VectorL10n.onboardingDisplayNamePlaceholder, "The textfield's placeholder should be set.") + + let footer = app.staticTexts["textFieldFooter"] + XCTAssertTrue(footer.exists, "The textfield's footer should always be shown.") + XCTAssertEqual(footer.label, VectorL10n.onboardingDisplayNameHint, "The footer should display a hint when an acceptable name is entered.") + } + + func verifyLongDisplayName(displayName: String) { + let textField = app.textFields.element + XCTAssertTrue(textField.exists, "The textfield should always be shown.") + XCTAssertEqual(textField.value as? String, displayName, "When a name has been set, it should show in the textfield.") + XCTAssertEqual(textField.placeholderValue, VectorL10n.onboardingDisplayNamePlaceholder, "The textfield's placeholder should be set.") + + let footer = app.staticTexts["textFieldFooter"] + XCTAssertTrue(footer.exists, "The textfield's footer should always be shown.") + XCTAssertEqual(footer.label, VectorL10n.onboardingDisplayNameMaxLength, "The footer should display an error when the display name is too long.") } - } diff --git a/RiotSwiftUI/Modules/Onboarding/DisplayName/Test/Unit/OnboardingDisplayNameViewModelTests.swift b/RiotSwiftUI/Modules/Onboarding/DisplayName/Test/Unit/OnboardingDisplayNameViewModelTests.swift index d419ecea4..765b31305 100644 --- a/RiotSwiftUI/Modules/Onboarding/DisplayName/Test/Unit/OnboardingDisplayNameViewModelTests.swift +++ b/RiotSwiftUI/Modules/Onboarding/DisplayName/Test/Unit/OnboardingDisplayNameViewModelTests.swift @@ -22,36 +22,47 @@ import Combine @available(iOS 14.0, *) class OnboardingDisplayNameViewModelTests: XCTestCase { private enum Constants { - static let presenceInitialValue: OnboardingDisplayNamePresence = .offline static let displayName = "Alice" } - var service: MockOnboardingDisplayNameService! - var viewModel: OnboardingDisplayNameViewModelProtocol! + + var viewModel: OnboardingDisplayNameViewModel! var context: OnboardingDisplayNameViewModelType.Context! - var cancellables = Set() + override func setUpWithError() throws { - service = MockOnboardingDisplayNameService(displayName: Constants.displayName, presence: Constants.presenceInitialValue) - viewModel = OnboardingDisplayNameViewModel.makeOnboardingDisplayNameViewModel(onboardingDisplayNameService: service) + viewModel = nil + context = nil + } + + func setUp(with displayName: String) { + viewModel = OnboardingDisplayNameViewModel(displayName: displayName) context = viewModel.context } - func testInitialState() { - XCTAssertEqual(context.viewState.displayName, Constants.displayName) - XCTAssertEqual(context.viewState.presence, Constants.presenceInitialValue) + func testValidDisplayName() { + // Given a short display name + let displayName = "Alice" + setUp(with: displayName) + + // When validating the display name + viewModel.process(viewAction: .validateDisplayName) + + // Then no error message should be set + XCTAssertEqual(context.viewState.bindings.displayName, displayName, "The display name should match the value used at init.") + XCTAssertNil(context.viewState.validationErrorMessage, "There should not be an error message in the view state.") } - - 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: OnboardingDisplayNamePresence = .online - let newPresenceValue2: OnboardingDisplayNamePresence = .idle - service.simulateUpdate(presence: newPresenceValue1) - service.simulateUpdate(presence: newPresenceValue2) - XCTAssertEqual(try awaitDeferred(), [Constants.presenceInitialValue, newPresenceValue1, newPresenceValue2]) + + func testInvalidDisplayName() { + // Given a short display name + let displayName = """ + Bacon ipsum dolor amet filet mignon chicken kevin andouille. Doner shoulder beef, brisket bresaola turkey jowl venison. Ham hock cow turducken, chislic venison doner short loin strip steak tri-tip jowl. Sirloin pork belly hamburger ribeye. Tail capicola alcatra short ribs turkey doner. + """ + setUp(with: displayName) + + // When validating the display name + viewModel.process(viewAction: .validateDisplayName) + + // Then no error message should be set + XCTAssertEqual(context.viewState.bindings.displayName, displayName, "The display name should match the value used at init.") + XCTAssertNotNil(context.viewState.validationErrorMessage, "There should be an error message in the view state.") } } diff --git a/RiotSwiftUI/Modules/Onboarding/DisplayName/View/OnboardingDisplayNameScreen.swift b/RiotSwiftUI/Modules/Onboarding/DisplayName/View/OnboardingDisplayNameScreen.swift index 5891de1d1..85e723fba 100644 --- a/RiotSwiftUI/Modules/Onboarding/DisplayName/View/OnboardingDisplayNameScreen.swift +++ b/RiotSwiftUI/Modules/Onboarding/DisplayName/View/OnboardingDisplayNameScreen.swift @@ -100,6 +100,7 @@ struct OnboardingDisplayNameScreen: View { .font(theme.fonts.footnote) .foregroundColor(textFieldFooterColor) .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityIdentifier("textFieldFooter") } } From 49497d1bf311753c056a0cb44587a75714628c82 Mon Sep 17 00:00:00 2001 From: Doug Date: Thu, 17 Mar 2022 19:08:25 +0000 Subject: [PATCH 34/38] Updates following self review. --- Riot/Modules/Camera/CameraPresenter.swift | 12 ++------ .../SingleImagePickerPresenter.swift | 12 ++++++-- .../Onboarding/OnboardingCoordinator.swift | 23 ++++++++++++--- .../PhotoPicker/PhotoPickerPresenter.swift | 8 ++--- .../OnboardingAvatarCoordinator.swift | 29 ++++++++----------- .../Avatar/OnboardingAvatarViewModel.swift | 6 ++-- .../OnboardingAvatarViewModelProtocol.swift | 5 +++- .../Test/UI/OnboardingAvatarUITests.swift | 8 +++++ .../Avatar/View/OnboardingAvatarScreen.swift | 1 + ...OnboardingCongratulationsCoordinator.swift | 2 +- .../OnboardingDisplayNameCoordinator.swift | 10 ++----- .../OnboardingDisplayNameViewModel.swift | 7 ++--- ...boardingDisplayNameViewModelProtocol.swift | 4 ++- .../UI/OnboardingDisplayNameUITests.swift | 12 ++++++++ .../OnboardingDisplayNameViewModelTests.swift | 4 --- .../View/OnboardingDisplayNameScreen.swift | 3 +- changelog.d/5652.wip | 1 + 17 files changed, 86 insertions(+), 61 deletions(-) create mode 100644 changelog.d/5652.wip diff --git a/Riot/Modules/Camera/CameraPresenter.swift b/Riot/Modules/Camera/CameraPresenter.swift index 12373bee9..863115d23 100644 --- a/Riot/Modules/Camera/CameraPresenter.swift +++ b/Riot/Modules/Camera/CameraPresenter.swift @@ -19,7 +19,7 @@ import UIKit import AVFoundation @objc protocol CameraPresenterDelegate: AnyObject { - func cameraPresenter(_ presenter: CameraPresenter, didSelectImageData imageData: Data, withUTI uti: MXKUTI?) + func cameraPresenter(_ presenter: CameraPresenter, didSelectImage image: UIImage) func cameraPresenter(_ presenter: CameraPresenter, didSelectVideoAt url: URL) func cameraPresenterDidCancel(_ cameraPresenter: CameraPresenter) } @@ -27,12 +27,6 @@ import AVFoundation /// CameraPresenter enables to present native camera @objc final class CameraPresenter: NSObject { - // MARK: - Constants - - private enum Constants { - static let jpegCompressionQuality: CGFloat = 1.0 - } - // MARK: - Properties // MARK: Private @@ -131,8 +125,8 @@ extension CameraPresenter: UIImagePickerControllerDelegate { func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) { if let videoURL = info[.mediaURL] as? URL { self.delegate?.cameraPresenter(self, didSelectVideoAt: videoURL) - } else if let image = (info[.editedImage] ?? info[.originalImage]) as? UIImage, let imageData = image.jpegData(compressionQuality: Constants.jpegCompressionQuality) { - self.delegate?.cameraPresenter(self, didSelectImageData: imageData, withUTI: MXKUTI.jpeg) + } else if let image = (info[.editedImage] ?? info[.originalImage]) as? UIImage { + self.delegate?.cameraPresenter(self, didSelectImage: image) } } diff --git a/Riot/Modules/MediaPicker/SingleImagePickerPresenter.swift b/Riot/Modules/MediaPicker/SingleImagePickerPresenter.swift index 1a1d20169..75135e8b2 100644 --- a/Riot/Modules/MediaPicker/SingleImagePickerPresenter.swift +++ b/Riot/Modules/MediaPicker/SingleImagePickerPresenter.swift @@ -27,6 +27,12 @@ import AVFoundation @objcMembers final class SingleImagePickerPresenter: NSObject { + // MARK: - Constants + + private enum Constants { + static let jpegCompressionQuality: CGFloat = 1.0 + } + // MARK: - Properties // MARK: Private @@ -117,8 +123,10 @@ final class SingleImagePickerPresenter: NSObject { // MARK: - CameraPresenterDelegate extension SingleImagePickerPresenter: CameraPresenterDelegate { - func cameraPresenter(_ cameraPresenter: CameraPresenter, didSelectImageData imageData: Data, withUTI uti: MXKUTI?) { - self.delegate?.singleImagePickerPresenter(self, didSelectImageData: imageData, withUTI: uti) + func cameraPresenter(_ cameraPresenter: CameraPresenter, didSelectImage image: UIImage) { + if let imageData = image.jpegData(compressionQuality: Constants.jpegCompressionQuality) { + self.delegate?.singleImagePickerPresenter(self, didSelectImageData: imageData, withUTI: MXKUTI.jpeg) + } } func cameraPresenterDidCancel(_ cameraPresenter: CameraPresenter) { diff --git a/Riot/Modules/Onboarding/OnboardingCoordinator.swift b/Riot/Modules/Onboarding/OnboardingCoordinator.swift index 52d977f0f..0a736cab1 100644 --- a/Riot/Modules/Onboarding/OnboardingCoordinator.swift +++ b/Riot/Modules/Onboarding/OnboardingCoordinator.swift @@ -251,8 +251,6 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { self.session = session self.authenticationType = authenticationType - // May need to move the spinner and key verification up to here in order to coordinate properly. - // Check whether another screen should be shown. if #available(iOS 14.0, *) { if authenticationType == .register, @@ -272,6 +270,10 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { completeIfReady() } + /// Checks the capabilities of the user's homeserver in order to determine + /// whether or not the display name and avatar can be updated. + /// + /// Once complete this method will start the post authentication flow automatically. @available(iOS 14.0, *) private func checkHomeserverCapabilities(for userSession: UserSession) { userSession.matrixSession.matrixRestClient.capabilities { [weak self] capabilities in @@ -308,11 +310,13 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { // MARK: - Post-Authentication + /// Starts the part of the flow that comes after authentication for new users. @available(iOS 14.0, *) private func beginPostAuthentication(for userSession: UserSession) { showCongratulationsScreen(for: userSession) } + /// Show the congratulations screen for new users. The screen will be configured based on the homeserver's capabilities. @available(iOS 14.0, *) private func showCongratulationsScreen(for userSession: UserSession) { MXLog.debug("[OnboardingCoordinator] showCongratulationsScreen") @@ -335,6 +339,7 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { } } + /// Displays the next view in the flow after the congratulations screen. @available(iOS 14.0, *) private func congratulationsCoordinator(_ coordinator: OnboardingCongratulationsCoordinator, didCompleteWith result: OnboardingCongratulationsCoordinatorResult) { switch result { @@ -360,6 +365,7 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { completeIfReady() } + /// Show the display name personalization screen for new users using the supplied user session. @available(iOS 14.0, *) private func showDisplayNameScreen(for userSession: UserSession) { MXLog.debug("[OnboardingCoordinator]: showDisplayNameScreen") @@ -380,6 +386,7 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { } } + /// Displays the next view in the flow after the display name screen. @available(iOS 14.0, *) private func displayNameCoordinator(_ coordinator: OnboardingDisplayNameCoordinator, didCompleteWith userSession: UserSession) { if shouldShowAvatarScreen { @@ -387,12 +394,14 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { return } else if Analytics.shared.shouldShowAnalyticsPrompt { showAnalyticsPrompt(for: userSession.matrixSession) + return } onboardingFinished = true completeIfReady() } + /// Show the avatar personalization screen for new users using the supplied user session. @available(iOS 14.0, *) private func showAvatarScreen(for userSession: UserSession) { MXLog.debug("[OnboardingCoordinator]: showAvatarScreen") @@ -408,8 +417,7 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { add(childCoordinator: coordinator) coordinator.start() - #warning("Should become root if display name was disabled.") - if navigationRouter.modules.isEmpty { + if navigationRouter.modules.isEmpty || !shouldShowDisplayNameScreen { navigationRouter.setRootModule(coordinator, hideNavigationBar: false, animated: true) { [weak self] in self?.remove(childCoordinator: coordinator) } @@ -420,6 +428,7 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { } } + /// Displays the next view in the flow after the avatar screen. @available(iOS 14.0, *) private func avatarCoordinator(_ coordinator: OnboardingAvatarCoordinator, didCompleteWith userSession: UserSession) { if Analytics.shared.shouldShowAnalyticsPrompt { @@ -431,6 +440,9 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { completeIfReady() } + /// Shows the analytics prompt for the supplied session. + /// + /// Check `Analytics.shared.shouldShowAnalyticsPrompt` before calling this method. @available(iOS 14.0, *) private func showAnalyticsPrompt(for session: MXSession) { MXLog.debug("[OnboardingCoordinator]: Invite the user to send analytics") @@ -452,6 +464,7 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { } } + /// Displays the next view in the flow after the analytics screen. private func analyticsPromptCoordinatorDidComplete(_ coordinator: AnalyticsPromptCoordinator) { onboardingFinished = true completeIfReady() @@ -459,6 +472,8 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { // MARK: - Finished + /// Calls the coordinator's completion handler if both `onboardingFinished` and `authenticationFinished` + /// are true. Otherwise displays any pending screens and waits to be called again. private func completeIfReady() { guard onboardingFinished else { MXLog.debug("[OnboardingCoordinator] Delaying onboarding completion until all screens have been shown.") diff --git a/Riot/Modules/PhotoPicker/PhotoPickerPresenter.swift b/Riot/Modules/PhotoPicker/PhotoPickerPresenter.swift index 87bfd2bb0..c4dde746f 100644 --- a/Riot/Modules/PhotoPicker/PhotoPickerPresenter.swift +++ b/Riot/Modules/PhotoPicker/PhotoPickerPresenter.swift @@ -34,7 +34,6 @@ final class PhotoPickerPresenter: NSObject { // MARK: Private private weak var pickerViewController: UIViewController? - private var filter: PHPickerFilter? private var indicatorPresenter: UserIndicatorTypePresenterProtocol? private var loadingIndicator: UserIndicator? @@ -45,6 +44,7 @@ final class PhotoPickerPresenter: NSObject { // MARK: - Public + // TODO: Support videos and multi-selection func presentPicker(from presentingViewController: UIViewController, with filter: PHPickerFilter?, animated: Bool) { var configuration = PHPickerConfiguration(photoLibrary: .shared()) configuration.selectionLimit = 1 @@ -71,7 +71,6 @@ final class PhotoPickerPresenter: NSObject { } func hideLoadingIndicator() { - loadingIndicator?.cancel() loadingIndicator = nil } } @@ -81,10 +80,7 @@ final class PhotoPickerPresenter: NSObject { extension PhotoPickerPresenter: PHPickerViewControllerDelegate { func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { // TODO: Handle videos and multi-selection - guard - let provider = results.first?.itemProvider, - provider.canLoadObject(ofClass: UIImage.self) - else { + guard let provider = results.first?.itemProvider, provider.canLoadObject(ofClass: UIImage.self) else { self.delegate?.photoPickerPresenterDidCancel(self) return } diff --git a/RiotSwiftUI/Modules/Onboarding/Avatar/Coordinator/OnboardingAvatarCoordinator.swift b/RiotSwiftUI/Modules/Onboarding/Avatar/Coordinator/OnboardingAvatarCoordinator.swift index 1296d0903..56139ec08 100644 --- a/RiotSwiftUI/Modules/Onboarding/Avatar/Coordinator/OnboardingAvatarCoordinator.swift +++ b/RiotSwiftUI/Modules/Onboarding/Avatar/Coordinator/OnboardingAvatarCoordinator.swift @@ -104,13 +104,8 @@ final class OnboardingAvatarCoordinator: Coordinator, Presentable { waitingIndicator = indicatorPresenter.present(.loading(label: VectorL10n.saving, isInteractionBlocking: true)) } - private func stopWaiting(error: Error? = nil) { - waitingIndicator?.cancel() + private func stopWaiting() { waitingIndicator = nil - - if let error = error { - onboardingAvatarViewModel.update(with: error) - } } private func pickImage() { @@ -123,11 +118,6 @@ final class OnboardingAvatarCoordinator: Coordinator, Presentable { cameraPresenter.presentCamera(from: controller, with: [.image], animated: true) } - #warning("Temporary") - func unknownError() -> Error { - MXError(errorCode: "M.UNKNOWN", error: "Something went wrong!").createNSError() - } - func setAvatar(_ image: UIImage?) { guard let image = image else { MXLog.error("[OnboardingAvatarCoordinator] setAvatar called with a nil image.") @@ -138,7 +128,8 @@ final class OnboardingAvatarCoordinator: Coordinator, Presentable { guard let avatarData = MXKTools.forceImageOrientationUp(image)?.jpegData(compressionQuality: 0.5) else { MXLog.error("[OnboardingAvatarCoordinator] Failed to create jpeg data.") - self.stopWaiting(error: self.unknownError()) + self.stopWaiting() + self.onboardingAvatarViewModel.processError(nil) return } @@ -146,7 +137,9 @@ final class OnboardingAvatarCoordinator: Coordinator, Presentable { guard let self = self else { return } guard let urlString = urlString else { - self.stopWaiting(error: self.unknownError()) + MXLog.error("[OnboardingAvatarCoordinator] Missing URL string for avatar.") + self.stopWaiting() + self.onboardingAvatarViewModel.processError(nil) return } @@ -156,11 +149,13 @@ final class OnboardingAvatarCoordinator: Coordinator, Presentable { self.completion?(self.parameters.userSession) } failure: { [weak self] error in guard let self = self else { return } - self.stopWaiting(error: error ?? self.unknownError()) + self.stopWaiting() + self.onboardingAvatarViewModel.processError(error as NSError?) } } failure: { [weak self] error in guard let self = self else { return } - self.stopWaiting(error: error ?? self.unknownError()) + self.stopWaiting() + self.onboardingAvatarViewModel.processError(error as NSError?) } } } @@ -183,8 +178,8 @@ extension OnboardingAvatarCoordinator: PhotoPickerPresenterDelegate { @available(iOS 14.0, *) extension OnboardingAvatarCoordinator: CameraPresenterDelegate { - func cameraPresenter(_ presenter: CameraPresenter, didSelectImageData imageData: Data, withUTI uti: MXKUTI?) { - onboardingAvatarViewModel.updateAvatarImage(with: UIImage(data: imageData)) + func cameraPresenter(_ presenter: CameraPresenter, didSelectImage image: UIImage) { + onboardingAvatarViewModel.updateAvatarImage(with: image) presenter.dismiss(animated: true, completion: nil) } diff --git a/RiotSwiftUI/Modules/Onboarding/Avatar/OnboardingAvatarViewModel.swift b/RiotSwiftUI/Modules/Onboarding/Avatar/OnboardingAvatarViewModel.swift index f1beeb33f..72376b34d 100644 --- a/RiotSwiftUI/Modules/Onboarding/Avatar/OnboardingAvatarViewModel.swift +++ b/RiotSwiftUI/Modules/Onboarding/Avatar/OnboardingAvatarViewModel.swift @@ -61,9 +61,7 @@ class OnboardingAvatarViewModel: OnboardingAvatarViewModelType, OnboardingAvatar state.avatar = image } - func update(with error: Error) { - if let error = error as NSError? { - state.bindings.alertInfo = AlertInfo(error: error) - } + func processError(_ error: NSError?) { + state.bindings.alertInfo = AlertInfo(error: error) } } diff --git a/RiotSwiftUI/Modules/Onboarding/Avatar/OnboardingAvatarViewModelProtocol.swift b/RiotSwiftUI/Modules/Onboarding/Avatar/OnboardingAvatarViewModelProtocol.swift index fa16204b6..0e757a9dd 100644 --- a/RiotSwiftUI/Modules/Onboarding/Avatar/OnboardingAvatarViewModelProtocol.swift +++ b/RiotSwiftUI/Modules/Onboarding/Avatar/OnboardingAvatarViewModelProtocol.swift @@ -22,7 +22,10 @@ protocol OnboardingAvatarViewModelProtocol { @available(iOS 14, *) var context: OnboardingAvatarViewModelType.Context { get } + /// Update the view model to show the image that the user has picked. func updateAvatarImage(with image: UIImage?) - func update(with error: Error) + /// Update the view model to show that an error has occurred. + /// - Parameter error: The error to be displayed or `nil` to display a generic alert. + func processError(_ error: NSError?) } diff --git a/RiotSwiftUI/Modules/Onboarding/Avatar/Test/UI/OnboardingAvatarUITests.swift b/RiotSwiftUI/Modules/Onboarding/Avatar/Test/UI/OnboardingAvatarUITests.swift index a77e77b39..f9cc94e70 100644 --- a/RiotSwiftUI/Modules/Onboarding/Avatar/Test/UI/OnboardingAvatarUITests.swift +++ b/RiotSwiftUI/Modules/Onboarding/Avatar/Test/UI/OnboardingAvatarUITests.swift @@ -50,6 +50,10 @@ class OnboardingAvatarUITests: MockScreenTest { let avatarImage = app.images["avatarImage"] XCTAssertFalse(avatarImage.exists, "The avatar image should be hidden as no selection has been made.") + + let saveButton = app.buttons["saveButton"] + XCTAssertTrue(saveButton.exists, "There should be a save button.") + XCTAssertFalse(saveButton.isEnabled, "The save button should not be enabled.") } func verifyUserSelectedAvatar() { @@ -58,5 +62,9 @@ class OnboardingAvatarUITests: MockScreenTest { let avatarImage = app.images["avatarImage"] XCTAssertTrue(avatarImage.exists, "The selected avatar should be shown.") + + let saveButton = app.buttons["saveButton"] + XCTAssertTrue(saveButton.exists, "There should be a save button.") + XCTAssertTrue(saveButton.isEnabled, "The save button should be enabled.") } } diff --git a/RiotSwiftUI/Modules/Onboarding/Avatar/View/OnboardingAvatarScreen.swift b/RiotSwiftUI/Modules/Onboarding/Avatar/View/OnboardingAvatarScreen.swift index 8ef8cb9fd..faab7e89f 100644 --- a/RiotSwiftUI/Modules/Onboarding/Avatar/View/OnboardingAvatarScreen.swift +++ b/RiotSwiftUI/Modules/Onboarding/Avatar/View/OnboardingAvatarScreen.swift @@ -127,6 +127,7 @@ struct OnboardingAvatarScreen: View { } .buttonStyle(PrimaryActionButtonStyle()) .disabled(viewModel.viewState.avatar == nil) + .accessibilityIdentifier("saveButton") Button { viewModel.send(viewAction: .skip) } label: { Text(VectorL10n.onboardingPersonalizationSkip) diff --git a/RiotSwiftUI/Modules/Onboarding/Congratulations/Coordinator/OnboardingCongratulationsCoordinator.swift b/RiotSwiftUI/Modules/Onboarding/Congratulations/Coordinator/OnboardingCongratulationsCoordinator.swift index 78f8ea5be..e76d19313 100644 --- a/RiotSwiftUI/Modules/Onboarding/Congratulations/Coordinator/OnboardingCongratulationsCoordinator.swift +++ b/RiotSwiftUI/Modules/Onboarding/Congratulations/Coordinator/OnboardingCongratulationsCoordinator.swift @@ -48,7 +48,7 @@ final class OnboardingCongratulationsCoordinator: Coordinator, Presentable { init(parameters: OnboardingCongratulationsCoordinatorParameters) { self.parameters = parameters - #warning("Add confetti when personalizationDisabled is false") + // TODO: Add confetti when personalizationDisabled is false let viewModel = OnboardingCongratulationsViewModel(userId: parameters.userSession.userId, personalizationDisabled: parameters.personalizationDisabled) let view = OnboardingCongratulationsScreen(viewModel: viewModel.context) diff --git a/RiotSwiftUI/Modules/Onboarding/DisplayName/Coordinator/OnboardingDisplayNameCoordinator.swift b/RiotSwiftUI/Modules/Onboarding/DisplayName/Coordinator/OnboardingDisplayNameCoordinator.swift index 94f992162..3f3be69f6 100644 --- a/RiotSwiftUI/Modules/Onboarding/DisplayName/Coordinator/OnboardingDisplayNameCoordinator.swift +++ b/RiotSwiftUI/Modules/Onboarding/DisplayName/Coordinator/OnboardingDisplayNameCoordinator.swift @@ -84,13 +84,8 @@ final class OnboardingDisplayNameCoordinator: Coordinator, Presentable { waitingIndicator = indicatorPresenter.present(.loading(label: VectorL10n.saving, isInteractionBlocking: true)) } - private func stopWaiting(error: Error? = nil) { - waitingIndicator?.cancel() + private func stopWaiting() { waitingIndicator = nil - - if let error = error { - onboardingDisplayNameViewModel.update(with: error) - } } private func setDisplayName(_ displayName: String) { @@ -102,7 +97,8 @@ final class OnboardingDisplayNameCoordinator: Coordinator, Presentable { self.completion?(self.parameters.userSession) } failure: { [weak self] error in guard let self = self else { return } - self.stopWaiting(error: error) + self.stopWaiting() + self.onboardingDisplayNameViewModel.processError(error as NSError?) } } } diff --git a/RiotSwiftUI/Modules/Onboarding/DisplayName/OnboardingDisplayNameViewModel.swift b/RiotSwiftUI/Modules/Onboarding/DisplayName/OnboardingDisplayNameViewModel.swift index e701cce08..e72b0b7be 100644 --- a/RiotSwiftUI/Modules/Onboarding/DisplayName/OnboardingDisplayNameViewModel.swift +++ b/RiotSwiftUI/Modules/Onboarding/DisplayName/OnboardingDisplayNameViewModel.swift @@ -52,14 +52,13 @@ class OnboardingDisplayNameViewModel: OnboardingDisplayNameViewModelType, Onboar } } - func update(with error: Error) { - if let error = error as NSError? { - state.bindings.alertInfo = AlertInfo(error: error) - } + func processError(_ error: NSError?) { + state.bindings.alertInfo = AlertInfo(error: error) } // MARK: - Private + /// Checks for a display name that exceeds 256 characters and updates the footer error if needed. private func validateDisplayName() { if state.bindings.displayName.count > 256 { guard state.validationErrorMessage == nil else { return } diff --git a/RiotSwiftUI/Modules/Onboarding/DisplayName/OnboardingDisplayNameViewModelProtocol.swift b/RiotSwiftUI/Modules/Onboarding/DisplayName/OnboardingDisplayNameViewModelProtocol.swift index e506ae1cd..d03c8416e 100644 --- a/RiotSwiftUI/Modules/Onboarding/DisplayName/OnboardingDisplayNameViewModelProtocol.swift +++ b/RiotSwiftUI/Modules/Onboarding/DisplayName/OnboardingDisplayNameViewModelProtocol.swift @@ -22,5 +22,7 @@ protocol OnboardingDisplayNameViewModelProtocol { @available(iOS 14, *) var context: OnboardingDisplayNameViewModelType.Context { get } - func update(with error: Error) + /// Update the view model to show that an error has occurred. + /// - Parameter error: The error to be displayed or `nil` to display a generic alert. + func processError(_ error: NSError?) } diff --git a/RiotSwiftUI/Modules/Onboarding/DisplayName/Test/UI/OnboardingDisplayNameUITests.swift b/RiotSwiftUI/Modules/Onboarding/DisplayName/Test/UI/OnboardingDisplayNameUITests.swift index 7ab52e933..30aa2ba50 100644 --- a/RiotSwiftUI/Modules/Onboarding/DisplayName/Test/UI/OnboardingDisplayNameUITests.swift +++ b/RiotSwiftUI/Modules/Onboarding/DisplayName/Test/UI/OnboardingDisplayNameUITests.swift @@ -49,6 +49,10 @@ class OnboardingDisplayNameUITests: MockScreenTest { let footer = app.staticTexts["textFieldFooter"] XCTAssertTrue(footer.exists, "The textfield's footer should always be shown.") XCTAssertEqual(footer.label, VectorL10n.onboardingDisplayNameHint, "The footer should display a hint when no text is set.") + + let saveButton = app.buttons["saveButton"] + XCTAssertTrue(saveButton.exists, "There should be a save button.") + XCTAssertFalse(saveButton.isEnabled, "The save button should not be enabled.") } func verifyDisplayName(displayName: String) { @@ -57,6 +61,10 @@ class OnboardingDisplayNameUITests: MockScreenTest { XCTAssertEqual(textField.value as? String, displayName, "When a name has been set, it should show in the textfield.") XCTAssertEqual(textField.placeholderValue, VectorL10n.onboardingDisplayNamePlaceholder, "The textfield's placeholder should be set.") + let saveButton = app.buttons["saveButton"] + XCTAssertTrue(saveButton.exists, "There should be a save button.") + XCTAssertTrue(saveButton.isEnabled, "The save button should be enabled.") + let footer = app.staticTexts["textFieldFooter"] XCTAssertTrue(footer.exists, "The textfield's footer should always be shown.") XCTAssertEqual(footer.label, VectorL10n.onboardingDisplayNameHint, "The footer should display a hint when an acceptable name is entered.") @@ -71,5 +79,9 @@ class OnboardingDisplayNameUITests: MockScreenTest { let footer = app.staticTexts["textFieldFooter"] XCTAssertTrue(footer.exists, "The textfield's footer should always be shown.") XCTAssertEqual(footer.label, VectorL10n.onboardingDisplayNameMaxLength, "The footer should display an error when the display name is too long.") + + let saveButton = app.buttons["saveButton"] + XCTAssertTrue(saveButton.exists, "There should be a save button.") + XCTAssertFalse(saveButton.isEnabled, "The save button should not be enabled.") } } diff --git a/RiotSwiftUI/Modules/Onboarding/DisplayName/Test/Unit/OnboardingDisplayNameViewModelTests.swift b/RiotSwiftUI/Modules/Onboarding/DisplayName/Test/Unit/OnboardingDisplayNameViewModelTests.swift index 765b31305..7f49a2aa2 100644 --- a/RiotSwiftUI/Modules/Onboarding/DisplayName/Test/Unit/OnboardingDisplayNameViewModelTests.swift +++ b/RiotSwiftUI/Modules/Onboarding/DisplayName/Test/Unit/OnboardingDisplayNameViewModelTests.swift @@ -21,10 +21,6 @@ import Combine @available(iOS 14.0, *) class OnboardingDisplayNameViewModelTests: XCTestCase { - private enum Constants { - static let displayName = "Alice" - } - var viewModel: OnboardingDisplayNameViewModel! var context: OnboardingDisplayNameViewModelType.Context! diff --git a/RiotSwiftUI/Modules/Onboarding/DisplayName/View/OnboardingDisplayNameScreen.swift b/RiotSwiftUI/Modules/Onboarding/DisplayName/View/OnboardingDisplayNameScreen.swift index 85e723fba..a678e1aa3 100644 --- a/RiotSwiftUI/Modules/Onboarding/DisplayName/View/OnboardingDisplayNameScreen.swift +++ b/RiotSwiftUI/Modules/Onboarding/DisplayName/View/OnboardingDisplayNameScreen.swift @@ -111,7 +111,8 @@ struct OnboardingDisplayNameScreen: View { viewModel.send(viewAction: .save) } .buttonStyle(PrimaryActionButtonStyle()) - .disabled(viewModel.displayName.isEmpty) + .disabled(viewModel.displayName.isEmpty || viewModel.viewState.validationErrorMessage != nil) + .accessibilityIdentifier("saveButton") Button { viewModel.send(viewAction: .skip) } label: { Text(VectorL10n.onboardingPersonalizationSkip) diff --git a/changelog.d/5652.wip b/changelog.d/5652.wip new file mode 100644 index 000000000..0d173fbd5 --- /dev/null +++ b/changelog.d/5652.wip @@ -0,0 +1 @@ +Onboarding: Add screens for setting a display name and avatar when signing up for the first time. \ No newline at end of file From 3a12162c5db4f179e44587cc8296947f97f5098c Mon Sep 17 00:00:00 2001 From: Doug Date: Thu, 17 Mar 2022 19:43:36 +0000 Subject: [PATCH 35/38] Fix voiceover support in OnboardingAvatarScreen. --- Riot/Assets/en.lproj/Untranslated.strings | 3 ++- Riot/Generated/UntranslatedStrings.swift | 8 ++++++++ .../Onboarding/Avatar/OnboardingAvatarModels.swift | 4 ++++ .../Onboarding/Avatar/View/OnboardingAvatarScreen.swift | 3 +++ 4 files changed, 17 insertions(+), 1 deletion(-) diff --git a/Riot/Assets/en.lproj/Untranslated.strings b/Riot/Assets/en.lproj/Untranslated.strings index 2f7e29d1d..cbac2d630 100644 --- a/Riot/Assets/en.lproj/Untranslated.strings +++ b/Riot/Assets/en.lproj/Untranslated.strings @@ -33,6 +33,7 @@ "onboarding_avatar_title" = "Add a profile picture"; "onboarding_avatar_message" = "You can change this anytime."; - +"onboarding_avatar_placeholder_accessibility_label" = "Profile picture, %@"; +"onboarding_avatar_image_accessibility_label" = "Profile picture, image"; "image_picker_action_files" = "Choose from files"; diff --git a/Riot/Generated/UntranslatedStrings.swift b/Riot/Generated/UntranslatedStrings.swift index 6531aaf96..1d5cb0301 100644 --- a/Riot/Generated/UntranslatedStrings.swift +++ b/Riot/Generated/UntranslatedStrings.swift @@ -14,10 +14,18 @@ public extension VectorL10n { static var imagePickerActionFiles: String { return VectorL10n.tr("Untranslated", "image_picker_action_files") } + /// Profile picture, image + static var onboardingAvatarImageAccessibilityLabel: String { + return VectorL10n.tr("Untranslated", "onboarding_avatar_image_accessibility_label") + } /// You can change this anytime. static var onboardingAvatarMessage: String { return VectorL10n.tr("Untranslated", "onboarding_avatar_message") } + /// Profile picture, %@ + public static func onboardingAvatarPlaceholderAccessibilityLabel(_ p1: String) -> String { + return VectorL10n.tr("Untranslated", "onboarding_avatar_placeholder_accessibility_label", p1) + } /// Add a profile picture static var onboardingAvatarTitle: String { return VectorL10n.tr("Untranslated", "onboarding_avatar_title") diff --git a/RiotSwiftUI/Modules/Onboarding/Avatar/OnboardingAvatarModels.swift b/RiotSwiftUI/Modules/Onboarding/Avatar/OnboardingAvatarModels.swift index 689aa7425..aced25fad 100644 --- a/RiotSwiftUI/Modules/Onboarding/Avatar/OnboardingAvatarModels.swift +++ b/RiotSwiftUI/Modules/Onboarding/Avatar/OnboardingAvatarModels.swift @@ -36,6 +36,10 @@ struct OnboardingAvatarViewState: BindableState { var buttonImage: ImageAsset { avatar == nil ? Asset.Images.onboardingAvatarCamera : Asset.Images.onboardingAvatarEdit } + + var avatarAccessibilityLabel: String { + avatar == nil ? VectorL10n.onboardingAvatarPlaceholderAccessibilityLabel(placeholderAvatarLetter) : VectorL10n.onboardingAvatarImageAccessibilityLabel + } } struct OnboardingAvatarBindings { diff --git a/RiotSwiftUI/Modules/Onboarding/Avatar/View/OnboardingAvatarScreen.swift b/RiotSwiftUI/Modules/Onboarding/Avatar/View/OnboardingAvatarScreen.swift index faab7e89f..b13d890c1 100644 --- a/RiotSwiftUI/Modules/Onboarding/Avatar/View/OnboardingAvatarScreen.swift +++ b/RiotSwiftUI/Modules/Onboarding/Avatar/View/OnboardingAvatarScreen.swift @@ -74,6 +74,9 @@ struct OnboardingAvatarScreen: View { .overlay(cameraButton, alignment: .bottomTrailing) .onTapGesture { isPresentingPickerSelection = true } .actionSheet(isPresented: $isPresentingPickerSelection) { pickerSelectionActionSheet } + .accessibilityElement(children: .ignore) + .accessibilityLabel(viewModel.viewState.avatarAccessibilityLabel) + .accessibilityValue(VectorL10n.accessibilityButtonLabel) } /// The button to indicate the user can tap to select an avatar From 052c8ba24af08f8a92ff66383615471e2144ca77 Mon Sep 17 00:00:00 2001 From: Doug Date: Mon, 21 Mar 2022 11:42:58 +0000 Subject: [PATCH 36/38] Address PR comments Add more docs. Rename PhotoPickerPresenter to MediaPickerPresenter. Use a Character for the placeholder avatar rather than a string. --- Riot/Assets/en.lproj/Untranslated.strings | 3 +-- Riot/Assets/en.lproj/Vector.strings | 1 + Riot/Generated/Strings.swift | 4 ++++ Riot/Generated/UntranslatedStrings.swift | 10 +++------ .../MediaPickerPresenter.swift} | 21 +++++++++++-------- .../Onboarding/OnboardingCoordinator.swift | 2 +- .../Avatar/View/PlaceholderAvatarImage.swift | 9 ++++++-- .../Common/Avatar/View/SpaceAvatarImage.swift | 2 +- .../Avatar/ViewModel/AvatarViewState.swift | 2 +- .../PlaceholderAvatarViewModel.swift | 9 +++----- .../OnboardingAvatarCoordinator.swift | 19 ++++++++++------- .../Avatar/OnboardingAvatarModels.swift | 19 ++++++++++++----- .../Avatar/View/OnboardingAvatarScreen.swift | 4 ++-- ...OnboardingCongratulationsCoordinator.swift | 11 +++++++--- .../OnboardingCongratulationsModels.swift | 2 +- .../OnboardingCongratulationsViewModel.swift | 2 +- .../OnboardingDisplayNameCoordinator.swift | 3 +++ .../OnboardingDisplayNameModels.swift | 9 ++++++++ 18 files changed, 84 insertions(+), 48 deletions(-) rename Riot/Modules/{PhotoPicker/PhotoPickerPresenter.swift => MediaPickerV2/MediaPickerPresenter.swift} (82%) diff --git a/Riot/Assets/en.lproj/Untranslated.strings b/Riot/Assets/en.lproj/Untranslated.strings index cbac2d630..51087b0b1 100644 --- a/Riot/Assets/en.lproj/Untranslated.strings +++ b/Riot/Assets/en.lproj/Untranslated.strings @@ -33,7 +33,6 @@ "onboarding_avatar_title" = "Add a profile picture"; "onboarding_avatar_message" = "You can change this anytime."; -"onboarding_avatar_placeholder_accessibility_label" = "Profile picture, %@"; -"onboarding_avatar_image_accessibility_label" = "Profile picture, image"; +"onboarding_avatar_accessibility_label" = "Profile picture"; "image_picker_action_files" = "Choose from files"; diff --git a/Riot/Assets/en.lproj/Vector.strings b/Riot/Assets/en.lproj/Vector.strings index 749b7fb46..fa9055e44 100644 --- a/Riot/Assets/en.lproj/Vector.strings +++ b/Riot/Assets/en.lproj/Vector.strings @@ -74,6 +74,7 @@ "ok" = "OK"; "error" = "Error"; "suggest" = "Suggest"; +"edit" = "Edit"; // Activities "loading" = "Loading"; diff --git a/Riot/Generated/Strings.swift b/Riot/Generated/Strings.swift index 290cf211e..c1d9ce44f 100644 --- a/Riot/Generated/Strings.swift +++ b/Riot/Generated/Strings.swift @@ -1651,6 +1651,10 @@ public class VectorL10n: NSObject { public static var e2eRoomKeyRequestTitle: String { return VectorL10n.tr("Vector", "e2e_room_key_request_title") } + /// Edit + public static var edit: String { + return VectorL10n.tr("Vector", "edit") + } /// Activities public static var emojiPickerActivityCategory: String { return VectorL10n.tr("Vector", "emoji_picker_activity_category") diff --git a/Riot/Generated/UntranslatedStrings.swift b/Riot/Generated/UntranslatedStrings.swift index 1d5cb0301..8be6e7646 100644 --- a/Riot/Generated/UntranslatedStrings.swift +++ b/Riot/Generated/UntranslatedStrings.swift @@ -14,18 +14,14 @@ public extension VectorL10n { static var imagePickerActionFiles: String { return VectorL10n.tr("Untranslated", "image_picker_action_files") } - /// Profile picture, image - static var onboardingAvatarImageAccessibilityLabel: String { - return VectorL10n.tr("Untranslated", "onboarding_avatar_image_accessibility_label") + /// Profile picture + static var onboardingAvatarAccessibilityLabel: String { + return VectorL10n.tr("Untranslated", "onboarding_avatar_accessibility_label") } /// You can change this anytime. static var onboardingAvatarMessage: String { return VectorL10n.tr("Untranslated", "onboarding_avatar_message") } - /// Profile picture, %@ - public static func onboardingAvatarPlaceholderAccessibilityLabel(_ p1: String) -> String { - return VectorL10n.tr("Untranslated", "onboarding_avatar_placeholder_accessibility_label", p1) - } /// Add a profile picture static var onboardingAvatarTitle: String { return VectorL10n.tr("Untranslated", "onboarding_avatar_title") diff --git a/Riot/Modules/PhotoPicker/PhotoPickerPresenter.swift b/Riot/Modules/MediaPickerV2/MediaPickerPresenter.swift similarity index 82% rename from Riot/Modules/PhotoPicker/PhotoPickerPresenter.swift rename to Riot/Modules/MediaPickerV2/MediaPickerPresenter.swift index c4dde746f..193a4cbee 100644 --- a/Riot/Modules/PhotoPicker/PhotoPickerPresenter.swift +++ b/Riot/Modules/MediaPickerV2/MediaPickerPresenter.swift @@ -19,15 +19,18 @@ import PhotosUI import CommonKit @available(iOS 14.0, *) -protocol PhotoPickerPresenterDelegate: AnyObject { - func photoPickerPresenter(_ presenter: PhotoPickerPresenter, didPickImage image: UIImage) - func photoPickerPresenterDidCancel(_ presenter: PhotoPickerPresenter) +protocol MediaPickerPresenterDelegate: AnyObject { + func mediaPickerPresenter(_ presenter: MediaPickerPresenter, didPickImage image: UIImage) + func mediaPickerPresenterDidCancel(_ presenter: MediaPickerPresenter) } /// A picker for photos and videos from the user's photo library on iOS 14+ using the /// new `PHPickerViewController` that doesn't require permission to be granted. +/// +/// **Note:** If you need to support iOS 12 & 13, then you will need to use the older +/// `MediaPickerCoordinator`/`MediaPickerViewController` instead. @available(iOS 14.0, *) -final class PhotoPickerPresenter: NSObject { +final class MediaPickerPresenter: NSObject { // MARK: - Properties @@ -40,7 +43,7 @@ final class PhotoPickerPresenter: NSObject { // MARK: Public - weak var delegate: PhotoPickerPresenterDelegate? + weak var delegate: MediaPickerPresenterDelegate? // MARK: - Public @@ -77,11 +80,11 @@ final class PhotoPickerPresenter: NSObject { // MARK: - PHPickerViewControllerDelegate @available(iOS 14, *) -extension PhotoPickerPresenter: PHPickerViewControllerDelegate { +extension MediaPickerPresenter: PHPickerViewControllerDelegate { func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { // TODO: Handle videos and multi-selection guard let provider = results.first?.itemProvider, provider.canLoadObject(ofClass: UIImage.self) else { - self.delegate?.photoPickerPresenterDidCancel(self) + self.delegate?.mediaPickerPresenterDidCancel(self) return } @@ -93,14 +96,14 @@ extension PhotoPickerPresenter: PHPickerViewControllerDelegate { guard let image = image as? UIImage else { DispatchQueue.main.async { self.hideLoadingIndicator() - self.delegate?.photoPickerPresenterDidCancel(self) + self.delegate?.mediaPickerPresenterDidCancel(self) } return } DispatchQueue.main.async { self.hideLoadingIndicator() - self.delegate?.photoPickerPresenter(self, didPickImage: image) + self.delegate?.mediaPickerPresenter(self, didPickImage: image) } } } diff --git a/Riot/Modules/Onboarding/OnboardingCoordinator.swift b/Riot/Modules/Onboarding/OnboardingCoordinator.swift index 0a736cab1..b9c99ad94 100644 --- a/Riot/Modules/Onboarding/OnboardingCoordinator.swift +++ b/Riot/Modules/Onboarding/OnboardingCoordinator.swift @@ -343,7 +343,7 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol { @available(iOS 14.0, *) private func congratulationsCoordinator(_ coordinator: OnboardingCongratulationsCoordinator, didCompleteWith result: OnboardingCongratulationsCoordinatorResult) { switch result { - case .personaliseProfile(let userSession): + case .personalizeProfile(let userSession): if shouldShowDisplayNameScreen { showDisplayNameScreen(for: userSession) return diff --git a/RiotSwiftUI/Modules/Common/Avatar/View/PlaceholderAvatarImage.swift b/RiotSwiftUI/Modules/Common/Avatar/View/PlaceholderAvatarImage.swift index f0f386eec..93f0a7186 100644 --- a/RiotSwiftUI/Modules/Common/Avatar/View/PlaceholderAvatarImage.swift +++ b/RiotSwiftUI/Modules/Common/Avatar/View/PlaceholderAvatarImage.swift @@ -17,6 +17,11 @@ import SwiftUI @available(iOS 14.0, *) +/// A reusable view that will show a standard placeholder avatar with the +/// supplied character and colour index for the `namesAndAvatars` color array. +/// +/// This view has a forced 1:1 aspect ratio but will appear very large until a `.frame` +/// modifier is applied. struct PlaceholderAvatarImage: View { // MARK: - Private @@ -25,7 +30,7 @@ struct PlaceholderAvatarImage: View { // MARK: - Public - let firstCharacter: String + let firstCharacter: Character let colorIndex: Int // MARK: - Views @@ -34,7 +39,7 @@ struct PlaceholderAvatarImage: View { ZStack { theme.colors.namesAndAvatars[colorIndex] - Text(firstCharacter) + Text(String(firstCharacter)) .padding(4) .foregroundColor(.white) // Make the text resizable (i.e. Make it large and then allow it to scale down) diff --git a/RiotSwiftUI/Modules/Common/Avatar/View/SpaceAvatarImage.swift b/RiotSwiftUI/Modules/Common/Avatar/View/SpaceAvatarImage.swift index 5908583b0..e0bb01e97 100644 --- a/RiotSwiftUI/Modules/Common/Avatar/View/SpaceAvatarImage.swift +++ b/RiotSwiftUI/Modules/Common/Avatar/View/SpaceAvatarImage.swift @@ -35,7 +35,7 @@ struct SpaceAvatarImage: View { case .empty: ProgressView() case .placeholder(let firstCharacter, let colorIndex): - Text(firstCharacter) + Text(String(firstCharacter)) .padding(10) .frame(width: CGFloat(size.rawValue), height: CGFloat(size.rawValue)) .foregroundColor(.white) diff --git a/RiotSwiftUI/Modules/Common/Avatar/ViewModel/AvatarViewState.swift b/RiotSwiftUI/Modules/Common/Avatar/ViewModel/AvatarViewState.swift index 203d3f9e9..cac2a70d4 100644 --- a/RiotSwiftUI/Modules/Common/Avatar/ViewModel/AvatarViewState.swift +++ b/RiotSwiftUI/Modules/Common/Avatar/ViewModel/AvatarViewState.swift @@ -19,6 +19,6 @@ import UIKit enum AvatarViewState { case empty - case placeholder(String, Int) + case placeholder(Character, Int) case avatar(UIImage) } diff --git a/RiotSwiftUI/Modules/Common/Avatar/ViewModel/PlaceholderAvatarViewModel.swift b/RiotSwiftUI/Modules/Common/Avatar/ViewModel/PlaceholderAvatarViewModel.swift index 806f24c97..d5c131b39 100644 --- a/RiotSwiftUI/Modules/Common/Avatar/ViewModel/PlaceholderAvatarViewModel.swift +++ b/RiotSwiftUI/Modules/Common/Avatar/ViewModel/PlaceholderAvatarViewModel.swift @@ -25,12 +25,9 @@ struct PlaceholderAvatarViewModel { /// The number of total colors available for the `stableColorIndex`. let colorCount: Int - /// Get the first character of the display name capitalized or else an empty string. - var firstCharacterCapitalized: String { - guard let character = displayName?.first else { - return "" - } - return String(character).capitalized + /// Get the first character of the display name capitalized or else a space character. + var firstCharacterCapitalized: Character { + return displayName?.capitalized.first ?? " " } /// Provides the same color each time for a specified matrixId diff --git a/RiotSwiftUI/Modules/Onboarding/Avatar/Coordinator/OnboardingAvatarCoordinator.swift b/RiotSwiftUI/Modules/Onboarding/Avatar/Coordinator/OnboardingAvatarCoordinator.swift index 56139ec08..70a9651d2 100644 --- a/RiotSwiftUI/Modules/Onboarding/Avatar/Coordinator/OnboardingAvatarCoordinator.swift +++ b/RiotSwiftUI/Modules/Onboarding/Avatar/Coordinator/OnboardingAvatarCoordinator.swift @@ -41,8 +41,8 @@ final class OnboardingAvatarCoordinator: Coordinator, Presentable { return presenter }() - private lazy var photoPickerPresenter: PhotoPickerPresenter = { - let presenter = PhotoPickerPresenter() + private lazy var mediaPickerPresenter: MediaPickerPresenter = { + let presenter = MediaPickerPresenter() presenter.delegate = self return presenter }() @@ -100,24 +100,29 @@ final class OnboardingAvatarCoordinator: Coordinator, Presentable { // MARK: - Private + /// Show a blocking activity indicator whilst saving. private func startWaiting() { waitingIndicator = indicatorPresenter.present(.loading(label: VectorL10n.saving, isInteractionBlocking: true)) } + /// Hide the currently displayed activity indicator. private func stopWaiting() { waitingIndicator = nil } + /// Present an image picker for the device photo library. private func pickImage() { let controller = toPresentable() - photoPickerPresenter.presentPicker(from: controller, with: .images, animated: true) + mediaPickerPresenter.presentPicker(from: controller, with: .images, animated: true) } + /// Present a camera view to take a photo to use for the avatar. private func takePhoto() { let controller = toPresentable() cameraPresenter.presentCamera(from: controller, with: [.image], animated: true) } + /// Set the supplied image as user's avatar, completing the screen's display if successful. func setAvatar(_ image: UIImage?) { guard let image = image else { MXLog.error("[OnboardingAvatarCoordinator] setAvatar called with a nil image.") @@ -160,16 +165,16 @@ final class OnboardingAvatarCoordinator: Coordinator, Presentable { } } -// MARK: - PhotoPickerPresenterDelegate +// MARK: - MediaPickerPresenterDelegate @available(iOS 14.0, *) -extension OnboardingAvatarCoordinator: PhotoPickerPresenterDelegate { - func photoPickerPresenter(_ presenter: PhotoPickerPresenter, didPickImage image: UIImage) { +extension OnboardingAvatarCoordinator: MediaPickerPresenterDelegate { + func mediaPickerPresenter(_ presenter: MediaPickerPresenter, didPickImage image: UIImage) { onboardingAvatarViewModel.updateAvatarImage(with: image) presenter.dismiss(animated: true, completion: nil) } - func photoPickerPresenterDidCancel(_ presenter: PhotoPickerPresenter) { + func mediaPickerPresenterDidCancel(_ presenter: MediaPickerPresenter) { presenter.dismiss(animated: true, completion: nil) } } diff --git a/RiotSwiftUI/Modules/Onboarding/Avatar/OnboardingAvatarModels.swift b/RiotSwiftUI/Modules/Onboarding/Avatar/OnboardingAvatarModels.swift index aced25fad..7f0187ebb 100644 --- a/RiotSwiftUI/Modules/Onboarding/Avatar/OnboardingAvatarModels.swift +++ b/RiotSwiftUI/Modules/Onboarding/Avatar/OnboardingAvatarModels.swift @@ -19,36 +19,45 @@ import UIKit // MARK: View model enum OnboardingAvatarViewModelResult { + /// The user would like to choose an image from their photo library. case pickImage + /// The user would like to take a photo to use as their avatar. case takePhoto + /// The user would like to set specified image as their avatar. case save(UIImage?) + /// Move on to the next screen in the flow without setting an avatar. case skip } // MARK: View struct OnboardingAvatarViewState: BindableState { - let placeholderAvatarLetter: String + /// The letter shown in the placeholder avatar. + let placeholderAvatarLetter: Character + /// The color index to use for the placeholder avatar's background. let placeholderAvatarColorIndex: Int + /// The image selected by the user to use as their avatar. var avatar: UIImage? var bindings: OnboardingAvatarBindings + /// The image shown in the avatar's button. var buttonImage: ImageAsset { avatar == nil ? Asset.Images.onboardingAvatarCamera : Asset.Images.onboardingAvatarEdit } - - var avatarAccessibilityLabel: String { - avatar == nil ? VectorL10n.onboardingAvatarPlaceholderAccessibilityLabel(placeholderAvatarLetter) : VectorL10n.onboardingAvatarImageAccessibilityLabel - } } struct OnboardingAvatarBindings { + /// The currently displayed alert's info value otherwise `nil`. var alertInfo: AlertInfo? } enum OnboardingAvatarViewAction { + /// The user would like to choose an image from their photo library. case pickImage + /// The user would like to take a photo to use as their avatar. case takePhoto + /// The user would like to save their chosen avatar image. case save + /// Move on to the next screen in the flow without setting an avatar. case skip } diff --git a/RiotSwiftUI/Modules/Onboarding/Avatar/View/OnboardingAvatarScreen.swift b/RiotSwiftUI/Modules/Onboarding/Avatar/View/OnboardingAvatarScreen.swift index b13d890c1..7e611900a 100644 --- a/RiotSwiftUI/Modules/Onboarding/Avatar/View/OnboardingAvatarScreen.swift +++ b/RiotSwiftUI/Modules/Onboarding/Avatar/View/OnboardingAvatarScreen.swift @@ -75,8 +75,8 @@ struct OnboardingAvatarScreen: View { .onTapGesture { isPresentingPickerSelection = true } .actionSheet(isPresented: $isPresentingPickerSelection) { pickerSelectionActionSheet } .accessibilityElement(children: .ignore) - .accessibilityLabel(viewModel.viewState.avatarAccessibilityLabel) - .accessibilityValue(VectorL10n.accessibilityButtonLabel) + .accessibilityLabel(VectorL10n.onboardingAvatarAccessibilityLabel) + .accessibilityValue(VectorL10n.edit) } /// The button to indicate the user can tap to select an avatar diff --git a/RiotSwiftUI/Modules/Onboarding/Congratulations/Coordinator/OnboardingCongratulationsCoordinator.swift b/RiotSwiftUI/Modules/Onboarding/Congratulations/Coordinator/OnboardingCongratulationsCoordinator.swift index e76d19313..47f6ee54b 100644 --- a/RiotSwiftUI/Modules/Onboarding/Congratulations/Coordinator/OnboardingCongratulationsCoordinator.swift +++ b/RiotSwiftUI/Modules/Onboarding/Congratulations/Coordinator/OnboardingCongratulationsCoordinator.swift @@ -17,12 +17,17 @@ import SwiftUI struct OnboardingCongratulationsCoordinatorParameters { + /// The user session used to determine the user ID to display. let userSession: UserSession + /// When `true` the "Personalise Profile" button will be hidden, preventing the + /// user from setting a displayname or avatar. let personalizationDisabled: Bool } enum OnboardingCongratulationsCoordinatorResult { - case personaliseProfile(UserSession) + /// Show the display name and/or avatar screens for the user to personalize their profile. + case personalizeProfile(UserSession) + /// Continue the flow by skipping the display name and avatar screens. case takeMeHome(UserSession) } @@ -64,8 +69,8 @@ final class OnboardingCongratulationsCoordinator: Coordinator, Presentable { MXLog.debug("[OnboardingCongratulationsCoordinator] OnboardingCongratulationsViewModel did complete with result: \(result).") switch result { - case .personaliseProfile: - self.completion?(.personaliseProfile(self.parameters.userSession)) + case .personalizeProfile: + self.completion?(.personalizeProfile(self.parameters.userSession)) case .takeMeHome: self.completion?(.takeMeHome(self.parameters.userSession)) } diff --git a/RiotSwiftUI/Modules/Onboarding/Congratulations/OnboardingCongratulationsModels.swift b/RiotSwiftUI/Modules/Onboarding/Congratulations/OnboardingCongratulationsModels.swift index 1c622a326..1a83694dc 100644 --- a/RiotSwiftUI/Modules/Onboarding/Congratulations/OnboardingCongratulationsModels.swift +++ b/RiotSwiftUI/Modules/Onboarding/Congratulations/OnboardingCongratulationsModels.swift @@ -21,7 +21,7 @@ import Foundation // MARK: View model enum OnboardingCongratulationsViewModelResult { - case personaliseProfile + case personalizeProfile case takeMeHome } diff --git a/RiotSwiftUI/Modules/Onboarding/Congratulations/OnboardingCongratulationsViewModel.swift b/RiotSwiftUI/Modules/Onboarding/Congratulations/OnboardingCongratulationsViewModel.swift index 39c6ff468..4e7924015 100644 --- a/RiotSwiftUI/Modules/Onboarding/Congratulations/OnboardingCongratulationsViewModel.swift +++ b/RiotSwiftUI/Modules/Onboarding/Congratulations/OnboardingCongratulationsViewModel.swift @@ -43,7 +43,7 @@ class OnboardingCongratulationsViewModel: OnboardingCongratulationsViewModelType override func process(viewAction: OnboardingCongratulationsViewAction) { switch viewAction { case .personaliseProfile: - completion?(.personaliseProfile) + completion?(.personalizeProfile) case .takeMeHome: completion?(.takeMeHome) } diff --git a/RiotSwiftUI/Modules/Onboarding/DisplayName/Coordinator/OnboardingDisplayNameCoordinator.swift b/RiotSwiftUI/Modules/Onboarding/DisplayName/Coordinator/OnboardingDisplayNameCoordinator.swift index 3f3be69f6..3e96952ae 100644 --- a/RiotSwiftUI/Modules/Onboarding/DisplayName/Coordinator/OnboardingDisplayNameCoordinator.swift +++ b/RiotSwiftUI/Modules/Onboarding/DisplayName/Coordinator/OnboardingDisplayNameCoordinator.swift @@ -80,14 +80,17 @@ final class OnboardingDisplayNameCoordinator: Coordinator, Presentable { // MARK: - Private + /// Show a blocking activity indicator whilst saving. private func startWaiting() { waitingIndicator = indicatorPresenter.present(.loading(label: VectorL10n.saving, isInteractionBlocking: true)) } + /// Hide the currently displayed activity indicator. private func stopWaiting() { waitingIndicator = nil } + /// Set the supplied string as user's display name, completing the screen's display if successful. private func setDisplayName(_ displayName: String) { startWaiting() diff --git a/RiotSwiftUI/Modules/Onboarding/DisplayName/OnboardingDisplayNameModels.swift b/RiotSwiftUI/Modules/Onboarding/DisplayName/OnboardingDisplayNameModels.swift index 8a584e379..22857f69c 100644 --- a/RiotSwiftUI/Modules/Onboarding/DisplayName/OnboardingDisplayNameModels.swift +++ b/RiotSwiftUI/Modules/Onboarding/DisplayName/OnboardingDisplayNameModels.swift @@ -19,7 +19,9 @@ import Foundation // MARK: View model enum OnboardingDisplayNameViewModelResult { + /// The user would like to save the entered display name. case save(String) + /// Move on to the next screen in the flow without setting a display name. case skip } @@ -27,20 +29,27 @@ enum OnboardingDisplayNameViewModelResult { struct OnboardingDisplayNameViewState: BindableState { var bindings: OnboardingDisplayNameBindings + /// Any error that occurred during display name validation otherwise `nil`. var validationErrorMessage: String? + /// The string to be displayed in the text field's footer. var textFieldFooterMessage: String { validationErrorMessage ?? VectorL10n.onboardingDisplayNameHint } } struct OnboardingDisplayNameBindings { + /// The display name string entered by the user. var displayName: String + /// The currently displayed alert's info value otherwise `nil`. var alertInfo: AlertInfo? } enum OnboardingDisplayNameViewAction { + /// The display name needs validation. case validateDisplayName + /// The user would like to save the entered display name. case save + /// Move on to the next screen in the flow without setting a display name. case skip } From fea3147f8caae441c329991dba38609d93f8b656 Mon Sep 17 00:00:00 2001 From: Doug Date: Mon, 21 Mar 2022 14:52:04 +0000 Subject: [PATCH 37/38] Fix crash trying to remove a UserSession for a nil MXSession. --- Riot/Modules/Application/AppCoordinator.swift | 3 ++- Riot/Modules/Application/LegacyAppDelegate.m | 6 +++++- changelog.d/5846.bugfix | 1 + 3 files changed, 8 insertions(+), 2 deletions(-) create mode 100644 changelog.d/5846.bugfix diff --git a/Riot/Modules/Application/AppCoordinator.swift b/Riot/Modules/Application/AppCoordinator.swift index fef10f5b8..e218a1a11 100755 --- a/Riot/Modules/Application/AppCoordinator.swift +++ b/Riot/Modules/Application/AppCoordinator.swift @@ -251,7 +251,8 @@ extension AppCoordinator: LegacyAppDelegateDelegate { func legacyAppDelegate(_ legacyAppDelegate: LegacyAppDelegate!, didAddMatrixSession session: MXSession!) { } - func legacyAppDelegate(_ legacyAppDelegate: LegacyAppDelegate!, didRemoveMatrixSession session: MXSession!) { + func legacyAppDelegate(_ legacyAppDelegate: LegacyAppDelegate!, didRemoveMatrixSession session: MXSession?) { + guard let session = session else { return } // Handle user session removal on clear cache. On clear cache the account has his session closed but the account is not removed. self.userSessionsService.removeUserSession(relatedToMatrixSession: session) } diff --git a/Riot/Modules/Application/LegacyAppDelegate.m b/Riot/Modules/Application/LegacyAppDelegate.m index f7c2dff3b..85180036a 100644 --- a/Riot/Modules/Application/LegacyAppDelegate.m +++ b/Riot/Modules/Application/LegacyAppDelegate.m @@ -2048,7 +2048,11 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni [[NSNotificationCenter defaultCenter] addObserverForName:kMXKAccountManagerDidSoftlogoutAccountNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notif) { MXKAccount *account = notif.object; - [self removeMatrixSession:account.mxSession]; + + if (account.mxSession) + { + [self removeMatrixSession:account.mxSession]; + } // Return to authentication screen [self.masterTabBarController showSoftLogoutOnboardingFlowWithCredentials:account.mxCredentials]; diff --git a/changelog.d/5846.bugfix b/changelog.d/5846.bugfix new file mode 100644 index 000000000..d37861d55 --- /dev/null +++ b/changelog.d/5846.bugfix @@ -0,0 +1 @@ +Authentication: Fix a crash that occurred when using the app with an account that had a soft logout. \ No newline at end of file From f335ea20220858a0d9e74178398936b03c0f5760 Mon Sep 17 00:00:00 2001 From: Doug Date: Mon, 21 Mar 2022 17:58:39 +0000 Subject: [PATCH 38/38] Fix missing } from rebase. --- Riot/Generated/Strings.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Riot/Generated/Strings.swift b/Riot/Generated/Strings.swift index c1d9ce44f..8a751f0d7 100644 --- a/Riot/Generated/Strings.swift +++ b/Riot/Generated/Strings.swift @@ -2714,6 +2714,7 @@ public class VectorL10n: NSObject { /// Live location enabled public static var liveLocationSharingBannerTitle: String { return VectorL10n.tr("Vector", "live_location_sharing_banner_title") + } /// Loading public static var loading: String { return VectorL10n.tr("Vector", "loading")