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)