From 9ff21657b72db2b22e5bab24bfdccc021bb5c69d Mon Sep 17 00:00:00 2001 From: Matthew Chen Date: Wed, 13 Jan 2021 14:30:54 -0300 Subject: [PATCH] Convert profile view to Swift. --- Signal.xcodeproj/project.pbxproj | 4 + .../AppSettings/AppSettingsViewController.m | 12 +- .../ViewControllers/ProfileViewController.m | 45 +- .../ProfileViewController.swift | 724 ++++++++++++++++++ 4 files changed, 757 insertions(+), 28 deletions(-) create mode 100644 Signal/src/ViewControllers/ProfileViewController.swift diff --git a/Signal.xcodeproj/project.pbxproj b/Signal.xcodeproj/project.pbxproj index 4c8592d534..a671e2fcc5 100644 --- a/Signal.xcodeproj/project.pbxproj +++ b/Signal.xcodeproj/project.pbxproj @@ -221,6 +221,7 @@ 347850691FD9B78A007B8332 /* AppSetup.m in Sources */ = {isa = PBXBuildFile; fileRef = 347850651FD9B789007B8332 /* AppSetup.m */; }; 3478506A1FD9B78A007B8332 /* AppSetup.h in Headers */ = {isa = PBXBuildFile; fileRef = 347850661FD9B789007B8332 /* AppSetup.h */; settings = {ATTRIBUTES = (Public, ); }; }; 3478506B1FD9B78A007B8332 /* NoopCallMessageHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 347850671FD9B78A007B8332 /* NoopCallMessageHandler.swift */; }; + 34798A8525AF370D00EC89B4 /* ProfileViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34798A8425AF370D00EC89B4 /* ProfileViewController.swift */; }; 347B83F724367EC10019A52C /* GroupViewHelper+MemberActionSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 347B83F624367EC00019A52C /* GroupViewHelper+MemberActionSheet.swift */; }; 347B83F92436820C0019A52C /* GroupViewHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 347B83F82436820C0019A52C /* GroupViewHelper.swift */; }; 347B83FD24378DDF0019A52C /* GroupMemberRequestsAndInvitesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 347B83FC24378DDE0019A52C /* GroupMemberRequestsAndInvitesViewController.swift */; }; @@ -1217,6 +1218,7 @@ 347850651FD9B789007B8332 /* AppSetup.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AppSetup.m; sourceTree = ""; }; 347850661FD9B789007B8332 /* AppSetup.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AppSetup.h; sourceTree = ""; }; 347850671FD9B78A007B8332 /* NoopCallMessageHandler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NoopCallMessageHandler.swift; sourceTree = ""; }; + 34798A8425AF370D00EC89B4 /* ProfileViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileViewController.swift; sourceTree = ""; }; 347B83F624367EC00019A52C /* GroupViewHelper+MemberActionSheet.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "GroupViewHelper+MemberActionSheet.swift"; sourceTree = ""; }; 347B83F82436820C0019A52C /* GroupViewHelper.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GroupViewHelper.swift; sourceTree = ""; }; 347B83FC24378DDE0019A52C /* GroupMemberRequestsAndInvitesViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GroupMemberRequestsAndInvitesViewController.swift; sourceTree = ""; }; @@ -2784,6 +2786,7 @@ 34969558219B605E00DCFE74 /* Photos */, 34CE88E51F2FB9A10098030F /* ProfileViewController.h */, 34CE88E61F2FB9A10098030F /* ProfileViewController.m */, + 34798A8425AF370D00EC89B4 /* ProfileViewController.swift */, 8835DDFB230CEE7600DC6B66 /* RecipientPicker */, 340FC875204DAC8C007AEB0F /* Registration */, 4585C4671ED8F8D200896AEA /* SafetyNumberConfirmationSheet.swift */, @@ -5315,6 +5318,7 @@ 340D900024FEE6A9007B5504 /* GroupInviteLinksUI.swift in Sources */, 34D2CCDA2062E7D000CB1A14 /* OWSScreenLockUI.m in Sources */, 887CD47B247304B600FDD265 /* DeviceTransferService+URL.swift in Sources */, + 34798A8525AF370D00EC89B4 /* ProfileViewController.swift in Sources */, 4CA46F4C219CCC630038ABDE /* CaptionView.swift in Sources */, 34BECE2E1F7ABCE000D7438D /* GifPickerViewController.swift in Sources */, 88588D1B252D3DFF00405414 /* CallControls.swift in Sources */, diff --git a/Signal/src/ViewControllers/AppSettings/AppSettingsViewController.m b/Signal/src/ViewControllers/AppSettings/AppSettingsViewController.m index 7cb86d737b..73fd94e2d1 100644 --- a/Signal/src/ViewControllers/AppSettings/AppSettingsViewController.m +++ b/Signal/src/ViewControllers/AppSettings/AppSettingsViewController.m @@ -346,12 +346,12 @@ - (void)showProfile { - ProfileViewController *profileVC = - [[ProfileViewController alloc] initWithMode:ProfileViewMode_AppSettings - completionHandler:^(ProfileViewController *completedVC) { - [completedVC.navigationController popViewControllerAnimated:YES]; - }]; - [self.navigationController pushViewController:profileVC animated:YES]; + UIViewController *vc = + [[ProfileViewController2 alloc] initWithMode:ProfileViewMode_AppSettings + completionHandler:^(ProfileViewController2 *completedVC) { + [completedVC.navigationController popViewControllerAnimated:YES]; + }]; + [self.navigationController pushViewController:vc animated:YES]; } - (void)showData diff --git a/Signal/src/ViewControllers/ProfileViewController.m b/Signal/src/ViewControllers/ProfileViewController.m index 7fa668c822..e3b934ecd1 100644 --- a/Signal/src/ViewControllers/ProfileViewController.m +++ b/Signal/src/ViewControllers/ProfileViewController.m @@ -1,5 +1,5 @@ // -// Copyright (c) 2020 Open Whisper Systems. All rights reserved. +// Copyright (c) 2021 Open Whisper Systems. All rights reserved. // #import "ProfileViewController.h" @@ -550,27 +550,28 @@ NSString *const kProfileView_LastPresentedDate = @"kProfileView_LastPresentedDat presentFromViewController:self canCancel:NO backgroundBlock:^(ModalActivityIndicatorViewController *modalActivityIndicator) { - [OWSProfileManager updateLocalProfilePromiseObjWithProfileGivenName:normalizedGivenName - profileFamilyName:normalizedFamilyName - profileAvatarData:weakSelf.avatarData] - - .then(^{ - OWSAssertIsOnMainThread(); - - [modalActivityIndicator dismissWithCompletion:^{ - [weakSelf updateProfileCompleted]; - }]; - }) - .catch(^(NSError *error) { - OWSAssertIsOnMainThread(); - OWSFailDebug(@"Error: %@", error); - - [modalActivityIndicator dismissWithCompletion:^{ - // Don't show an error alert; the profile update - // is enqueued and will be completed later. - [weakSelf updateProfileCompleted]; - }]; - }); + // [OWSProfileManager + // updateLocalProfilePromiseObjWithProfileGivenName:normalizedGivenName + // profileFamilyName:normalizedFamilyName + // profileAvatarData:weakSelf.avatarData] + // + // .then(^{ + // OWSAssertIsOnMainThread(); + // + // [modalActivityIndicator dismissWithCompletion:^{ + // [weakSelf updateProfileCompleted]; + // }]; + // }) + // .catch(^(NSError *error) { + // OWSAssertIsOnMainThread(); + // OWSFailDebug(@"Error: %@", error); + // + // [modalActivityIndicator dismissWithCompletion:^{ + // // Don't show an error alert; the profile update + // // is enqueued and will be completed later. + // [weakSelf updateProfileCompleted]; + // }]; + // }); }]; } diff --git a/Signal/src/ViewControllers/ProfileViewController.swift b/Signal/src/ViewControllers/ProfileViewController.swift new file mode 100644 index 0000000000..54c603293a --- /dev/null +++ b/Signal/src/ViewControllers/ProfileViewController.swift @@ -0,0 +1,724 @@ +// +// Copyright (c) 2021 Open Whisper Systems. All rights reserved. +// + +import Foundation +import SafariServices +import PromiseKit + +//@objc +//public enum ProfileViewMode: UInt { +// case appSettings +// case registration +// case experienceUpgrade +//} + +// MARK: - + +@objc +public class ProfileViewController2: OWSTableViewController { + + private let avatarViewHelper = AvatarViewHelper() + + private static func buildTextField() -> UITextField { + if UIDevice.current.isIPhone5OrShorter { + return DismissableTextField() + } else { + return OWSTextField() + } + } + + private lazy var givenNameTextField = Self.buildTextField() + + private lazy var familyNameTextField = Self.buildTextField() + + private let profileNamePreviewLabel = UILabel() + + private let usernameLabel = UILabel() + + private let avatarView = AvatarImageView() + + private var saveButton: OWSFlatButton? + + private var avatarData: Data? + + private var hasUnsavedChanges = false { + didSet { + updateNavigationItem() + } + } + + private let mode: ProfileViewMode + + private let completionHandler: (ProfileViewController2) -> Void + + @objc + public required init(mode: ProfileViewMode, + completionHandler: @escaping (ProfileViewController2) -> Void) { + + self.mode = mode + self.completionHandler = completionHandler + + super.init() + + databaseStorage.asyncWrite { transaction in + Self.keyValueStore.setDate(Date(), key: Self.lastPresentedDateKey, transaction: transaction) + } + } + + // MARK: - Orientation + + public override var supportedInterfaceOrientations: UIInterfaceOrientationMask { + if UIDevice.current.isIPad { + return .all + } + return (mode == .registration ? .portrait : .allButUpsideDown) + } + + // MARK: - + + private static let keyValueStore = SDSKeyValueStore(collection: "kProfileView_Collection") + + private static let lastPresentedDateKey = "kProfileView_LastPresentedDate" + + static var shouldDisplayProfileViewOnLaunch: Bool { + // Only nag until the user sets a profile _name_. Profile names are + // recommended profile avatars are optional. + if let localGivenName = profileManager.localGivenName(), + !localGivenName.isEmpty { + return false + } + + let kProfileNagFrequency = kDayInterval * 30 + guard let lastPresentedDate = (databaseStorage.read { transaction in + Self.keyValueStore.getDate(lastPresentedDateKey, transaction: transaction) + }) else { + return true + } + return abs(lastPresentedDate.timeIntervalSinceNow) > kProfileNagFrequency + } + + // MARK: - + + public override func loadView() { + super.loadView() + + self.useThemeBackgroundColors = true + + self.title = NSLocalizedString("PROFILE_VIEW_TITLE", comment: "Title for the profile view.") + + avatarViewHelper.delegate = self + + avatarData = profileManager.localProfileAvatarData() + + createViews() + updateNavigationItem() + + if mode == .registration { + // mark as dirty if re-registration has content + if let familyName = familyNameTextField.text, + !familyName.isEmpty { + hasUnsavedChanges = true + } else if let givenName = givenNameTextField.text, + !givenName.isEmpty { + hasUnsavedChanges = true + } else if avatarData != nil { + hasUnsavedChanges = true + } + } + + updateTableContents() + } + + public override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + updateNavigationItem() + + switch mode { + case .appSettings: + break + case .registration, .experienceUpgrade: + givenNameTextField.becomeFirstResponder() + @unknown default: + owsFailDebug("Unknown value.") + } + + updateUsername() + } + + public override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + updateNavigationItem() + } + + private func createViews() { + avatarView.autoSetDimensions(to: CGSize.square(96)) + avatarView.accessibilityIdentifier = "avatar_view" + + givenNameTextField.returnKeyType = .next + givenNameTextField.autocorrectionType = .no + givenNameTextField.spellCheckingType = .no + givenNameTextField.placeholder = NSLocalizedString("PROFILE_VIEW_GIVEN_NAME_DEFAULT_TEXT", + comment: "Default text for the given name field of the profile view.") + givenNameTextField.delegate = self + givenNameTextField.textAlignment = .right + givenNameTextField.accessibilityIdentifier = "given_name_textfield" + givenNameTextField.addTarget(self, action: #selector(textFieldDidChange), for: .editingChanged) + + givenNameTextField.returnKeyType = .done + givenNameTextField.autocorrectionType = .no + givenNameTextField.spellCheckingType = .no + familyNameTextField.placeholder = NSLocalizedString("PROFILE_VIEW_FAMILY_NAME_DEFAULT_TEXT", + comment: "Default text for the family name field of the profile view.") + familyNameTextField.delegate = self + familyNameTextField.textAlignment = .right + familyNameTextField.accessibilityIdentifier = "family_name_textfield" + familyNameTextField.addTarget(self, action: #selector(textFieldDidChange), for: .editingChanged) + + profileNamePreviewLabel.textAlignment = .center + profileNamePreviewLabel.textColor = Theme.secondaryTextAndIconColor + profileNamePreviewLabel.font = .ows_dynamicTypeSubheadlineClamped + } + + func updateTableContents() { + let contents = OWSTableContents() + + let givenNameTextField = self.givenNameTextField + let familyNameTextField = self.familyNameTextField + let avatarView = self.avatarView + let profileNamePreviewLabel = self.profileNamePreviewLabel + + givenNameTextField.text = Self.profileManager.localGivenName() + familyNameTextField.text = Self.profileManager.localFamilyName() + updateAvatarView() + updateProfileNamePreview() + + let avatarSection = OWSTableSection() + avatarSection.add(OWSTableItem(customCellBlock: { () -> UITableViewCell in + let cell = OWSTableItem.newCell() + + let stackView = UIStackView(arrangedSubviews: [avatarView, profileNamePreviewLabel]) + stackView.axis = .vertical + stackView.alignment = .center + stackView.spacing = 16 + stackView.layoutMargins = UIEdgeInsets(top: 24, leading: 0, bottom: 32, trailing: 0) + stackView.isLayoutMarginsRelativeArrangement = true + cell.contentView.addSubview(stackView) + stackView.autoPinEdgesToSuperviewMargins() + + let cameraImageView = UIImageView.withTemplateImageName("camera-outline-24", tintColor: Theme.secondaryTextAndIconColor) + cameraImageView.autoSetDimensions(to: CGSize.square(32)) + cameraImageView.contentMode = .center + cameraImageView.backgroundColor = Theme.backgroundColor + cameraImageView.layer.cornerRadius = 16 + cameraImageView.layer.shadowColor = (Theme.isDarkThemeEnabled ? Theme.darkThemeWashColor : Theme.primaryTextColor).cgColor + cameraImageView.layer.shadowOffset = CGSize.square(1) + cameraImageView.layer.shadowOpacity = 0.5 + cameraImageView.layer.shadowRadius = 4 + stackView.addSubview(cameraImageView) + cameraImageView.autoPinTrailing(toEdgeOf: avatarView) + cameraImageView.autoPinEdge(.bottom, to: .bottom, of: avatarView) + + return cell + }, + actionBlock: { [weak self] in + self?.didTapAvatar() + })) + contents.addSection(avatarSection) + + let namesSection = OWSTableSection() + func addGivenNameRow() { + namesSection.add(OWSTableItem(customCellBlock: { () -> UITableViewCell in + + givenNameTextField.font = .ows_dynamicTypeBodyClamped + givenNameTextField.textColor = Theme.primaryTextColor + + return Self.buildNameCell(title: NSLocalizedString("PROFILE_VIEW_GIVEN_NAME_FIELD", + comment: "Label for the given name field of the profile view."), + valueView: givenNameTextField, + accessibilityIdentifier: "given_name") + }, + actionBlock: { + givenNameTextField.becomeFirstResponder() + })) + } + func addFamilyNameRow() { + namesSection.add(OWSTableItem(customCellBlock: { () -> UITableViewCell in + + familyNameTextField.font = .ows_dynamicTypeBodyClamped + familyNameTextField.textColor = Theme.primaryTextColor + + return Self.buildNameCell(title: NSLocalizedString("PROFILE_VIEW_FAMILY_NAME_FIELD", + comment: "Label for the family name field of the profile view."), + valueView: familyNameTextField, + accessibilityIdentifier: "given_name") + }, + actionBlock: { + familyNameTextField.becomeFirstResponder() + })) + } + // For CJKV locales, display family name field first. + if NSLocale.current.isCJKV { + addFamilyNameRow() + addGivenNameRow() + + // Otherwise, display given name field first. + } else { + addGivenNameRow() + addFamilyNameRow() + } + if shouldShowUsernameRow { + namesSection.add(buildUsernameItem()) + } + contents.addSection(namesSection) + + let lastSection = namesSection + + // Information + lastSection.add(OWSTableItem(customCellBlock: { () -> UITableViewCell in + let cell = OWSTableItem.newCell() + + let label = UILabel() + label.textColor = Theme.secondaryTextAndIconColor + label.font = .ows_dynamicTypeCaption1Clamped + let attributedText = NSMutableAttributedString() + attributedText.append(NSLocalizedString("PROFILE_VIEW_PROFILE_DESCRIPTION", + comment: "Description of the user profile.")) + attributedText.append(" ") + attributedText.append(NSAttributedString(string: CommonStrings.learnMore, + attributes: [ + NSAttributedString.Key.foregroundColor: Theme.accentBlueColor, + NSAttributedString.Key.underlineStyle: 0 + ])) + label.attributedText = attributedText + label.numberOfLines = 0 + label.lineBreakMode = .byWordWrapping + + cell.contentView.addSubview(label) + label.autoPinEdgesToSuperviewMargins() + + cell.backgroundColor = Theme.tableViewBackgroundColor + cell.contentView.backgroundColor = Theme.tableViewBackgroundColor + + return cell + }, + actionBlock: { [weak self] in + self?.didTapInfo() + })) + + // Big Button + + if mode == .experienceUpgrade { + let buttonSection = OWSTableSection() + let target = self + let selector = #selector(updateProfile) + buttonSection.add(OWSTableItem(customCellBlock: { () -> UITableViewCell in + let cell = OWSTableItem.newCell() + + let buttonHeight: CGFloat = 47 + let button = OWSFlatButton.button(title: NSLocalizedString("PROFILE_VIEW_SAVE_BUTTON", + comment: "Button to save the profile view in the profile view."), + font: OWSFlatButton.fontForHeight(buttonHeight), + titleColor: UIColor.ows_white, + backgroundColor: UIColor.ows_accentBlue, + target: target, + selector: selector) + button.accessibilityIdentifier = "save_button" + button.autoSetDimension(.height, toSize: buttonHeight) + cell.contentView.addSubview(button) + button.autoPinEdgesToSuperviewMargins() + + cell.backgroundColor = Theme.tableViewBackgroundColor + cell.contentView.backgroundColor = Theme.tableViewBackgroundColor + + return cell + }, + actionBlock: { [weak self] in + self?.updateProfile() + })) + contents.addSection(buttonSection) + } + + self.contents = contents + } + + private func buildUsernameItem() -> OWSTableItem { + updateUsername() + + let usernameLabel = self.usernameLabel + return OWSTableItem(customCellBlock: { () -> UITableViewCell in + let title = NSLocalizedString("PROFILE_VIEW_USERNAME_FIELD", + comment: "Label for the username field of the profile view.") + + let disclosureIconName = CurrentAppContext().isRTL ? "NavBarBack" : "NavBarBackRTL" + let disclosureImageView = UIImageView.withTemplateImageName(disclosureIconName, + tintColor: UIColor(rgbHex: 0xcccccc)) + + let disclosureContainer = UIView.container() + disclosureContainer.addSubview(disclosureImageView) + disclosureImageView.autoPinWidthToSuperview() + disclosureImageView.autoVCenterInSuperview() + + usernameLabel.font = .ows_dynamicTypeBodyClamped + usernameLabel.textAlignment = .right + + return Self.buildNameCell(title: title, + valueView: usernameLabel, + accessibilityIdentifier: "username", + accessoryView: disclosureContainer) + }, + actionBlock: { [weak self] in + self?.didTapUsername() + }) + } + + private static func buildNameLabel(title: String) -> UILabel { + let label = UILabel() + label.text = title + label.textColor = Theme.primaryTextColor + label.font = UIFont.ows_dynamicTypeBodyClamped.ows_semibold + label.setCompressionResistanceHorizontalHigh() + label.setContentHuggingHorizontalHigh() + return label + } + + private static func buildNameCell(title: String, + valueView: UIView, + accessibilityIdentifier: String, + accessoryView: UIView? = nil) -> UITableViewCell { + let cell = OWSTableItem.newCell() + + let titleLabel = buildNameLabel(title: title) + + valueView.setCompressionResistanceHorizontalLow() + valueView.setContentHuggingHorizontalLow() + + var subviews = [titleLabel, valueView] + if let accessoryView = accessoryView { + accessoryView.setCompressionResistanceHorizontalHigh() + accessoryView.setContentHuggingHorizontalHigh() + subviews.append(accessoryView) + } + let stackView = UIStackView(arrangedSubviews: subviews) + stackView.axis = .horizontal + stackView.alignment = .center + stackView.spacing = 10 + cell.contentView.addSubview(stackView) + stackView.autoPinEdgesToSuperviewMargins() + + cell.accessibilityIdentifier = accessibilityIdentifier + + return cell + } + + // MARK: - Event Handling + + private func leaveViewCheckingForUnsavedChanges() { + familyNameTextField.resignFirstResponder() + givenNameTextField.resignFirstResponder() + + if !hasUnsavedChanges { + // If user made no changes, return to conversation settings view. + profileCompleted() + return + } + + OWSActionSheets.showPendingChangesActionSheet(discardAction: { [weak self] in + self?.profileCompleted() + }) + } + + private func updateNavigationItem() { + guard let navigationController = navigationController else { + return + } + // The navigation bar is hidden in the registration workflow. + if navigationController.isNavigationBarHidden { + navigationController.setNavigationBarHidden(false, animated: true) + } + + var forceSaveButtonEnabled = false + + switch mode { + case .appSettings: + if hasUnsavedChanges { + // If we have a unsaved changes, right item should be a "save" button. + let saveButton = UIBarButtonItem(barButtonSystemItem: .save, + target: self, + action: #selector(updateProfile), + accessibilityIdentifier: "save_button") + navigationItem.rightBarButtonItem = saveButton + } else { + navigationItem.rightBarButtonItem = nil + } + case .registration: + navigationItem.hidesBackButton = true + navigationItem.rightBarButtonItem = nil + + // During registration, if you have a name pre-populatd we want + // to enable the save button even if you haven't edited anything. + if let givenName = givenNameTextField.text, + !givenName.isEmpty { + forceSaveButtonEnabled = true + } + case .experienceUpgrade: + navigationItem.rightBarButtonItem = nil + + // During the experience upgrade, if you have a name we want + // to enable the save button even if you haven't edited anything. + if let givenName = givenNameTextField.text, + !givenName.isEmpty { + forceSaveButtonEnabled = true + } + @unknown default: + owsFailDebug("Unknown mode.") + navigationItem.rightBarButtonItem = nil + } + + if let saveButton = saveButton { + if hasUnsavedChanges || forceSaveButtonEnabled { + saveButton.setEnabled(true) + saveButton.setBackgroundColors(upColor: .ows_accentBlue) + } else { + saveButton.setEnabled(false) + saveButton.setBackgroundColors(upColor: UIColor.ows_accentBlue.blended(with: Theme.backgroundColor, + alpha: 0.5)) + } + } + } + + @objc + func updateProfile() { + + let normalizedGivenName = self.normalizedGivenName + let normalizedFamilyName = self.normalizedFamilyName + + if normalizedGivenName.isEmpty { + OWSActionSheets.showErrorAlert(message: NSLocalizedString("PROFILE_VIEW_ERROR_GIVEN_NAME_REQUIRED", + comment: "Error message shown when user tries to update profile without a given name")) + return + } + + if profileManager.isProfileNameTooLong(normalizedGivenName) { + OWSActionSheets.showErrorAlert(message: NSLocalizedString("PROFILE_VIEW_ERROR_GIVEN_NAME_TOO_LONG", + comment: "Error message shown when user tries to update profile with a given name that is too long.")) + return + } + + if profileManager.isProfileNameTooLong(normalizedFamilyName) { + OWSActionSheets.showErrorAlert(message: NSLocalizedString("PROFILE_VIEW_ERROR_FAMILY_NAME_TOO_LONG", + comment: "Error message shown when user tries to update profile with a family name that is too long.")) + return + } + + if !self.reachabilityManager.isReachable { + OWSActionSheets.showErrorAlert(message: NSLocalizedString("PROFILE_VIEW_NO_CONNECTION", + comment: "Error shown when the user tries to update their profile when the app is not connected to the internet.")) + return + } + + // Show an activity indicator to block the UI during the profile upload. + let avatarData = self.avatarData + ModalActivityIndicatorViewController.present(fromViewController: self, + canCancel: false) { modalActivityIndicator in + firstly(on: .global()) { () -> Promise in + OWSProfileManager.updateLocalProfilePromise(profileGivenName: normalizedGivenName, + profileFamilyName: normalizedFamilyName, + profileAvatarData: avatarData) + }.done { _ in + modalActivityIndicator.dismiss { [weak self] in + AssertIsOnMainThread() + self?.profileCompleted() + } + }.catch { error in + owsFailDebug("Error: \(error)") + modalActivityIndicator.dismiss { [weak self] in + AssertIsOnMainThread() + self?.profileCompleted() + } + } + } + } + + private var normalizedGivenName: String { + (givenNameTextField.text ?? "").ows_stripped() + } + + private var normalizedFamilyName: String { + (familyNameTextField.text ?? "").ows_stripped() + } + + private func profileCompleted() { + AssertIsOnMainThread() + Logger.verbose("") + + completionHandler(self) + } + + // MARK: - Avatar + + private func setAvatarImage(_ avatarImage: UIImage?) { + AssertIsOnMainThread() + + var avatarData: Data? + if let avatarImage = avatarImage { + avatarData = OWSProfileManager.avatarData(forAvatarImage: avatarImage) + } + hasUnsavedChanges = hasUnsavedChanges || avatarData != self.avatarData + self.avatarData = avatarData + + updateAvatarView() + } + + private let avatarSize: UInt = 96 + + private func updateAvatarView() { + if let avatarData = avatarData { + avatarView.image = UIImage(data: avatarData) + } else { + avatarView.image = OWSContactAvatarBuilder(forLocalUserWithDiameter: avatarSize).buildDefaultImage() + } + } + + private func updateProfileNamePreview() { + var components = PersonNameComponents() + components.givenName = normalizedGivenName + components.familyName = normalizedFamilyName + + let previewText = PersonNameComponentsFormatter.localizedString(from: components, + style: .`default`) + if previewText.isEmpty { + profileNamePreviewLabel.text = " " + } else { + profileNamePreviewLabel.text = previewText + } + } + + private func updateUsername() { + if let username = profileManager.localUsername() { + usernameLabel.text = CommonFormats.formatUsername(username) + usernameLabel.textColor = Theme.primaryTextColor + } else { + usernameLabel.text = NSLocalizedString("PROFILE_VIEW_CREATE_USERNAME", + comment: "A string indicating that the user can create a username on the profile view.") + usernameLabel.textColor = Theme.accentBlueColor + } + } + + private func didTapUsername() { + let usernameVC = UsernameViewController() + if mode == .registration { + usernameVC.modalPresentation = true + presentFormSheet(OWSNavigationController(rootViewController: usernameVC), animated: true) + } else { + navigationController?.pushViewController(usernameVC, animated: true) + } + } + + private var shouldShowUsernameRow: Bool { + switch mode { + case .experienceUpgrade, .registration: + return false + case .appSettings: + return RemoteConfig.usernames + @unknown default: + owsFailDebug("Unknown value.") + return false + } + } + + private func didTapAvatar() { + avatarViewHelper.showChangeAvatarUI() + } + + private func didTapInfo() { + let vc = SFSafariViewController(url: URL(string: "https://support.signal.org/hc/articles/360007459591")!) + present(vc, animated: true, completion: nil) + } +} + +// MARK: - + +extension ProfileViewController2: AvatarViewHelperDelegate { + + public func avatarActionSheetTitle() -> String? { + NSLocalizedString("PROFILE_VIEW_AVATAR_ACTIONSHEET_TITLE", comment: "Action Sheet title prompting the user for a profile avatar") + } + + public func avatarDidChange(_ image: UIImage) { + AssertIsOnMainThread() + + setAvatarImage(image.resizedImage(toFillPixelSize: .square(CGFloat(kOWSProfileManager_MaxAvatarDiameter)))) + } + + public func fromViewController() -> UIViewController { + self + } + + public func hasClearAvatarAction() -> Bool { + avatarData != nil + } + + public func clearAvatar() { + setAvatarImage(nil) + } + + public func clearAvatarActionLabel() -> String { + return NSLocalizedString("PROFILE_VIEW_CLEAR_AVATAR", comment: "Label for action that clear's the user's profile avatar") + } +} + +// MARK: - + +extension ProfileViewController2: UITextFieldDelegate { + + private var firstTextField: UITextField { + NSLocale.current.isCJKV ? familyNameTextField : givenNameTextField + } + + private var secondTextField: UITextField { + NSLocale.current.isCJKV ? givenNameTextField : familyNameTextField + } + + public func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { + TextFieldHelper.textField( + textField, + shouldChangeCharactersInRange: range, + replacementString: string.withoutBidiControlCharacters, + byteLimit: OWSUserProfile.kNameDataLength + ) + } + + public func textFieldShouldReturn(_ textField: UITextField) -> Bool { + if textField == firstTextField { + secondTextField.becomeFirstResponder() + } else { + textField.resignFirstResponder() + } + return false + } + + @objc + func textFieldDidChange(_ textField: UITextField) { + hasUnsavedChanges = true + + updateProfileNamePreview() + + // TODO: Update length warning. + } +} + +// MARK: - + +extension ProfileViewController2: OWSNavigationView { + + public func shouldCancelNavigationBack() -> Bool { + let result = hasUnsavedChanges + if result { + leaveViewCheckingForUnsavedChanges() + } + return result + } +}