diff options
| author | Andrej Mihajlov <and@mullvad.net> | 2023-03-07 15:16:59 +0100 |
|---|---|---|
| committer | Andrej Mihajlov <and@mullvad.net> | 2023-03-22 16:42:30 +0100 |
| commit | a51757ce590b5063c1c8099b3ed8ea0fa8b3bcdb (patch) | |
| tree | 85935823680100affad563ebeca45a07a71938ee /ios/MullvadVPN/View controllers/ProblemReport | |
| parent | 1c2c6f58dc1d175d00bea8037ca989ca80b1fcb8 (diff) | |
| download | mullvadvpn-a51757ce590b5063c1c8099b3ed8ea0fa8b3bcdb.tar.xz mullvadvpn-a51757ce590b5063c1c8099b3ed8ea0fa8b3bcdb.zip | |
Add coordinators and app router
Fixes IOS-10
Diffstat (limited to 'ios/MullvadVPN/View controllers/ProblemReport')
4 files changed, 1139 insertions, 0 deletions
diff --git a/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportInteractor.swift b/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportInteractor.swift new file mode 100644 index 0000000000..bac2c375a4 --- /dev/null +++ b/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportInteractor.swift @@ -0,0 +1,64 @@ +// +// ProblemReportInteractor.swift +// MullvadVPN +// +// Created by pronebird on 25/10/2022. +// Copyright © 2022 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import MullvadREST +import MullvadTypes +import Operations + +final class ProblemReportInteractor { + private let apiProxy: REST.APIProxy + private let tunnelManager: TunnelManager + + private lazy var consolidatedLog: ConsolidatedApplicationLog = { + let securityGroupIdentifier = ApplicationConfiguration.securityGroupIdentifier + + // TODO: make sure we redact old tokens + + let redactStrings = [tunnelManager.deviceState.accountData?.number].compactMap { $0 } + + let report = ConsolidatedApplicationLog( + redactCustomStrings: redactStrings, + redactContainerPathsForSecurityGroupIdentifiers: [securityGroupIdentifier] + ) + + report.addLogFiles(fileURLs: ApplicationConfiguration.logFileURLs, includeLogBackups: true) + + return report + }() + + init(apiProxy: REST.APIProxy, tunnelManager: TunnelManager) { + self.apiProxy = apiProxy + self.tunnelManager = tunnelManager + } + + func sendReport( + email: String, + message: String, + completion: @escaping (Result<Void, Error>) -> Void + ) -> Cancellable { + let request = REST.ProblemReportRequest( + address: email, + message: message, + log: consolidatedLog.string, + metadata: consolidatedLog.metadata.reduce(into: [:]) { output, entry in + output[entry.key.rawValue] = entry.value + } + ) + + return apiProxy.sendProblemReport( + request, + retryStrategy: .default, + completionHandler: completion + ) + } + + var reportString: String { + return consolidatedLog.string + } +} diff --git a/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportReviewViewController.swift b/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportReviewViewController.swift new file mode 100644 index 0000000000..4effda0bc5 --- /dev/null +++ b/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportReviewViewController.swift @@ -0,0 +1,90 @@ +// +// ProblemReportReviewViewController.swift +// MullvadVPN +// +// Created by pronebird on 10/02/2021. +// Copyright © 2021 Mullvad VPN AB. All rights reserved. +// + +import UIKit + +class ProblemReportReviewViewController: UIViewController { + private var textView = UITextView() + private let reportString: String + + init(reportString: String) { + self.reportString = reportString + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + navigationItem.title = NSLocalizedString( + "NAVIGATION_TITLE", + tableName: "ProblemReportReview", + value: "App logs", + comment: "" + ) + + navigationItem.rightBarButtonItem = UIBarButtonItem( + barButtonSystemItem: .done, + target: self, + action: #selector(handleDismissButton(_:)) + ) + + #if DEBUG + navigationItem.leftBarButtonItem = UIBarButtonItem( + barButtonSystemItem: .action, + target: self, + action: #selector(share(_:)) + ) + #endif + + textView.translatesAutoresizingMaskIntoConstraints = false + textView.text = reportString + textView.isEditable = false + textView.font = UIFont.monospacedSystemFont( + ofSize: UIFont.systemFontSize, + weight: .regular + ) + + view.addSubview(textView) + + NSLayoutConstraint.activate([ + textView.topAnchor.constraint(equalTo: view.topAnchor), + textView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + textView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + textView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + + // Used to layout constraints so that navigation controller could properly adjust the text + // view insets. + view.layoutIfNeeded() + } + + override func selectAll(_ sender: Any?) { + textView.selectAll(sender) + } + + // MARK: - Actions + + @objc func handleDismissButton(_ sender: Any) { + dismiss(animated: true) + } + + #if DEBUG + @objc func share(_ sender: Any) { + let activityController = UIActivityViewController( + activityItems: [reportString], + applicationActivities: nil + ) + + present(activityController, animated: true) + } + #endif +} diff --git a/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportSubmissionOverlayView.swift b/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportSubmissionOverlayView.swift new file mode 100644 index 0000000000..0b5a8b5bf5 --- /dev/null +++ b/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportSubmissionOverlayView.swift @@ -0,0 +1,263 @@ +// +// ProblemReportSubmissionOverlayView.swift +// MullvadVPN +// +// Created by pronebird on 12/02/2021. +// Copyright © 2021 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import MullvadREST +import UIKit + +class ProblemReportSubmissionOverlayView: UIView { + var editButtonAction: (() -> Void)? + var retryButtonAction: (() -> Void)? + + enum State { + case sending + case sent(_ email: String) + case failure(Error) + + var title: String? { + switch self { + case .sending: + return NSLocalizedString( + "SUBMISSION_STATUS_SENDING", + tableName: "ProblemReport", + value: "Sending...", + comment: "" + ) + case .sent: + return NSLocalizedString( + "SUBMISSION_STATUS_SENT", + tableName: "ProblemReport", + value: "Sent", + comment: "" + ) + case .failure: + return NSLocalizedString( + "SUBMISSION_STATUS_FAILURE", + tableName: "ProblemReport", + value: "Failed to send", + comment: "" + ) + } + } + + var body: NSAttributedString? { + switch self { + case .sending: + return nil + case let .sent(email): + let combinedAttributedString = NSMutableAttributedString( + string: NSLocalizedString( + "THANKS_MESSAGE", + tableName: "ProblemReport", + value: "Thanks!", + comment: "" + ), + attributes: [.foregroundColor: UIColor.successColor] + ) + + if email.isEmpty { + combinedAttributedString.append(NSAttributedString(string: " ")) + combinedAttributedString.append( + NSAttributedString( + string: NSLocalizedString( + "WE_WILL_LOOK_INTO_THIS_MESSAGE", + tableName: "ProblemReport", + value: "We will look into this.", + comment: "" + ) + ) + ) + } else { + let emailText = String( + format: NSLocalizedString( + "CONTACT_BACK_EMAIL_MESSAGE_FORMAT", + tableName: "ProblemReport", + value: "If needed we will contact you at %@", + comment: "" + ), email + ) + let emailAttributedString = NSMutableAttributedString(string: emailText) + if let emailRange = emailText.range(of: email) { + let font = UIFont.systemFont(ofSize: 17, weight: .bold) + let nsRange = NSRange(emailRange, in: emailText) + + emailAttributedString.addAttribute(.font, value: font, range: nsRange) + } + + combinedAttributedString.append(NSAttributedString(string: " ")) + combinedAttributedString.append(emailAttributedString) + } + + return combinedAttributedString + + case let .failure(error): + if let error = error as? REST.Error { + return error.displayErrorDescription.flatMap { NSAttributedString(string: $0) } + } else { + return NSAttributedString(string: error.localizedDescription) + } + } + } + } + + var state: State = .sending { + didSet { + transitionToState(state) + } + } + + let activityIndicator: SpinnerActivityIndicatorView = { + let indicator = SpinnerActivityIndicatorView(style: .large) + indicator.tintColor = .white + return indicator + }() + + let statusImageView = StatusImageView(style: .success) + + let titleLabel: UILabel = { + let textLabel = UILabel() + textLabel.font = UIFont.systemFont(ofSize: 32) + textLabel.textColor = .white + textLabel.numberOfLines = 0 + return textLabel + }() + + let bodyLabel: UILabel = { + let textLabel = UILabel() + textLabel.font = UIFont.systemFont(ofSize: 17) + textLabel.textColor = .white + textLabel.numberOfLines = 0 + return textLabel + }() + + /// Footer stack view that contains action buttons + private lazy var buttonsStackView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [self.editMessageButton, self.tryAgainButton]) + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .vertical + stackView.spacing = 18 + + return stackView + }() + + private lazy var editMessageButton: AppButton = { + let button = AppButton(style: .default) + button.translatesAutoresizingMaskIntoConstraints = false + button.setTitle(NSLocalizedString( + "EDIT_MESSAGE_BUTTON", + tableName: "ProblemReport", + value: "Edit message", + comment: "" + ), for: .normal) + button.addTarget(self, action: #selector(handleEditButton), for: .touchUpInside) + return button + }() + + private lazy var tryAgainButton: AppButton = { + let button = AppButton(style: .success) + button.translatesAutoresizingMaskIntoConstraints = false + button.setTitle(NSLocalizedString( + "TRY_AGAIN_BUTTON", + tableName: "ProblemReport", + value: "Try again", + comment: "" + ), for: .normal) + button.addTarget(self, action: #selector(handleRetryButton), for: .touchUpInside) + return button + }() + + override init(frame: CGRect) { + super.init(frame: frame) + + addSubviews() + transitionToState(state) + + layoutMargins = UIMetrics.contentLayoutMargins + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func addSubviews() { + for subview in [ + titleLabel, + bodyLabel, + activityIndicator, + statusImageView, + buttonsStackView, + ] { + subview.translatesAutoresizingMaskIntoConstraints = false + addSubview(subview) + } + + NSLayoutConstraint.activate([ + statusImageView.topAnchor.constraint( + equalTo: layoutMarginsGuide.topAnchor, + constant: 32 + ), + statusImageView.centerXAnchor.constraint(equalTo: centerXAnchor), + + activityIndicator.centerXAnchor.constraint(equalTo: statusImageView.centerXAnchor), + activityIndicator.centerYAnchor.constraint(equalTo: statusImageView.centerYAnchor), + + titleLabel.topAnchor.constraint(equalTo: statusImageView.bottomAnchor, constant: 60), + titleLabel.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), + titleLabel.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor), + + bodyLabel.topAnchor.constraint( + equalToSystemSpacingBelow: titleLabel.bottomAnchor, + multiplier: 1 + ), + bodyLabel.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), + bodyLabel.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor), + buttonsStackView.topAnchor.constraint( + greaterThanOrEqualTo: bodyLabel.bottomAnchor, + constant: 18 + ), + + buttonsStackView.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), + buttonsStackView.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor), + buttonsStackView.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor), + ]) + } + + private func transitionToState(_ state: State) { + titleLabel.text = state.title + bodyLabel.attributedText = state.body + + switch state { + case .sending: + activityIndicator.startAnimating() + statusImageView.isHidden = true + buttonsStackView.isHidden = true + + case .sent: + activityIndicator.stopAnimating() + statusImageView.style = .success + statusImageView.isHidden = false + buttonsStackView.isHidden = true + + case .failure: + activityIndicator.stopAnimating() + statusImageView.style = .failure + statusImageView.isHidden = false + buttonsStackView.isHidden = false + } + } + + // MARK: - Actions + + @objc private func handleEditButton() { + editButtonAction?() + } + + @objc private func handleRetryButton() { + retryButtonAction?() + } +} diff --git a/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportViewController.swift b/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportViewController.swift new file mode 100644 index 0000000000..4a1bd4596b --- /dev/null +++ b/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportViewController.swift @@ -0,0 +1,722 @@ +// +// ProblemReportViewController.swift +// MullvadVPN +// +// Created by pronebird on 15/09/2020. +// Copyright © 2020 Mullvad VPN AB. All rights reserved. +// + +import MullvadREST +import MullvadTypes +import Operations +import UIKit + +final class ProblemReportViewController: UIViewController, UITextFieldDelegate { + private let interactor: ProblemReportInteractor + + private var textViewKeyboardResponder: AutomaticKeyboardResponder? + private var scrollViewKeyboardResponder: AutomaticKeyboardResponder? + + /// Scroll view + private lazy var scrollView: UIScrollView = { + let scrollView = UIScrollView() + scrollView.translatesAutoresizingMaskIntoConstraints = false + scrollView.backgroundColor = .clear + return scrollView + }() + + /// Scroll view content container + private lazy var containerView: UIView = { + let containerView = UIView() + containerView.translatesAutoresizingMaskIntoConstraints = false + containerView.layoutMargins = UIMetrics.contentLayoutMargins + containerView.backgroundColor = .clear + return containerView + }() + + /// Subheading label displayed below navigation bar + private lazy var subheaderLabel: UILabel = { + let textLabel = UILabel() + textLabel.translatesAutoresizingMaskIntoConstraints = false + textLabel.numberOfLines = 0 + textLabel.textColor = .white + textLabel.text = NSLocalizedString( + "SUBHEAD_LABEL", + tableName: "ProblemReport", + value: "To help you more effectively, your app's log file will be attached to this message. Your data will remain secure and private, as it is anonymised before being sent over an encrypted channel.", + comment: "" + ) + return textLabel + }() + + private lazy var emailTextField: CustomTextField = { + let textField = CustomTextField() + textField.translatesAutoresizingMaskIntoConstraints = false + textField.delegate = self + textField.keyboardType = .emailAddress + textField.textContentType = .emailAddress + textField.autocorrectionType = .no + textField.autocapitalizationType = .none + textField.smartInsertDeleteType = .no + textField.returnKeyType = .next + textField.borderStyle = .none + textField.backgroundColor = .white + textField.inputAccessoryView = emailAccessoryToolbar + textField.font = UIFont.systemFont(ofSize: 17) + textField.placeholder = NSLocalizedString( + "EMAIL_TEXTFIELD_PLACEHOLDER", + tableName: "ProblemReport", + value: "Your email (optional)", + comment: "" + ) + + return textField + }() + + private lazy var messageTextView: CustomTextView = { + let textView = CustomTextView() + textView.translatesAutoresizingMaskIntoConstraints = false + textView.backgroundColor = .white + textView.inputAccessoryView = messageAccessoryToolbar + textView.font = UIFont.systemFont(ofSize: 17) + textView.placeholder = NSLocalizedString( + "DESCRIPTION_TEXTVIEW_PLACEHOLDER", + tableName: "ProblemReport", + value: "Please describe your problem in English or Swedish", + comment: "" + ) + textView.contentInsetAdjustmentBehavior = .never + + return textView + }() + + /// Container view for text input fields + private lazy var textFieldsHolder: UIView = { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + /// Constraints used when description text view is active + private var activeMessageTextViewConstraints = [NSLayoutConstraint]() + + /// Constraints used when description text view is inactive + private var inactiveMessageTextViewConstraints = [NSLayoutConstraint]() + + /// Flag indicating when the text view is expanded to fill the entire view + private var isMessageTextViewExpanded = false + + /// Placeholder view used to fill the space within the scroll view when the text view is + /// expanded to fill the entire view + private lazy var messagePlaceholder: UIView = { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + view.backgroundColor = .clear + return view + }() + + /// Footer stack view that contains action buttons + private lazy var buttonsStackView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [self.viewLogsButton, self.sendButton]) + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .vertical + stackView.spacing = 18 + + return stackView + }() + + private lazy var viewLogsButton: AppButton = { + let button = AppButton(style: .default) + button.translatesAutoresizingMaskIntoConstraints = false + button.setTitle(NSLocalizedString( + "VIEW_APP_LOGS_BUTTON_TITLE", + tableName: "ProblemReport", + value: "View app logs", + comment: "" + ), for: .normal) + button.addTarget(self, action: #selector(handleViewLogsButtonTap), for: .touchUpInside) + return button + }() + + private lazy var sendButton: AppButton = { + let button = AppButton(style: .success) + button.translatesAutoresizingMaskIntoConstraints = false + button.setTitle(NSLocalizedString( + "SEND_BUTTON_TITLE", + tableName: "ProblemReport", + value: "Send", + comment: "" + ), for: .normal) + button.addTarget(self, action: #selector(handleSendButtonTap), for: .touchUpInside) + return button + }() + + private lazy var emailAccessoryToolbar: UIToolbar = makeKeyboardToolbar( + canGoBackward: false, + canGoForward: true + ) + + private lazy var messageAccessoryToolbar: UIToolbar = makeKeyboardToolbar( + canGoBackward: true, + canGoForward: false + ) + + private lazy var submissionOverlayView: ProblemReportSubmissionOverlayView = { + let overlay = ProblemReportSubmissionOverlayView() + overlay.translatesAutoresizingMaskIntoConstraints = false + + overlay.editButtonAction = { [weak self] in + self?.hideSubmissionOverlay() + } + + overlay.retryButtonAction = { [weak self] in + self?.sendProblemReport() + } + + return overlay + }() + + // MARK: - View lifecycle + + override var preferredStatusBarStyle: UIStatusBarStyle { + return .lightContent + } + + override var disablesAutomaticKeyboardDismissal: Bool { + // Allow dismissing the keyboard in .formSheet presentation style + return false + } + + init(interactor: ProblemReportInteractor) { + self.interactor = interactor + + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = .secondaryColor + + navigationItem.title = NSLocalizedString( + "NAVIGATION_TITLE", + tableName: "ProblemReport", + value: "Report a problem", + comment: "" + ) + + textViewKeyboardResponder = AutomaticKeyboardResponder(targetView: messageTextView) + scrollViewKeyboardResponder = AutomaticKeyboardResponder(targetView: scrollView) + + // Make sure that the user can't easily dismiss the controller on iOS 13 and above + isModalInPresentation = true + + // Set hugging & compression priorities so that description text view wants to grow + emailTextField.setContentHuggingPriority(.defaultHigh, for: .vertical) + emailTextField.setContentCompressionResistancePriority(.defaultHigh, for: .vertical) + messageTextView.setContentHuggingPriority(.defaultLow, for: .vertical) + messageTextView.setContentCompressionResistancePriority(.defaultLow, for: .vertical) + + textFieldsHolder.addSubview(emailTextField) + textFieldsHolder.addSubview(messagePlaceholder) + textFieldsHolder.addSubview(messageTextView) + + view.addSubview(scrollView) + scrollView.addSubview(containerView) + containerView.addSubview(subheaderLabel) + containerView.addSubview(textFieldsHolder) + containerView.addSubview(buttonsStackView) + + addConstraints() + registerForNotifications() + + loadPersistentViewModel() + } + + override func viewSafeAreaInsetsDidChange() { + super.viewSafeAreaInsetsDidChange() + + scrollViewKeyboardResponder?.updateContentInsets() + textViewKeyboardResponder?.updateContentInsets() + } + + // MARK: - Actions + + @objc func focusEmailTextField() { + emailTextField.becomeFirstResponder() + } + + @objc func focusDescriptionTextView() { + messageTextView.becomeFirstResponder() + } + + @objc func dismissKeyboard() { + view.endEditing(false) + } + + @objc func handleSendButtonTap() { + let proceedWithSubmission = { + self.sendProblemReport() + } + + if Self.persistentViewModel.email.isEmpty { + presentEmptyEmailConfirmationAlert { shouldSend in + if shouldSend { + proceedWithSubmission() + } + } + } else { + proceedWithSubmission() + } + } + + @objc func handleViewLogsButtonTap() { + let reviewController = ProblemReportReviewViewController( + reportString: interactor.reportString + ) + let navigationController = UINavigationController(rootViewController: reviewController) + + present(navigationController, animated: true) + } + + // MARK: - Private + + private func registerForNotifications() { + let notificationCenter = NotificationCenter.default + notificationCenter.addObserver( + self, + selector: #selector(emailTextFieldDidChange), + name: UITextField.textDidChangeNotification, + object: emailTextField + ) + notificationCenter.addObserver( + self, + selector: #selector(messageTextViewDidBeginEditing), + name: UITextView.textDidBeginEditingNotification, + object: messageTextView + ) + notificationCenter.addObserver( + self, + selector: #selector(messageTextViewDidEndEditing), + name: UITextView.textDidEndEditingNotification, + object: messageTextView + ) + notificationCenter.addObserver( + self, + selector: #selector(messageTextViewDidChange), + name: UITextView.textDidChangeNotification, + object: messageTextView + ) + } + + private func makeKeyboardToolbar(canGoBackward: Bool, canGoForward: Bool) -> UIToolbar { + var toolbarItems = UIBarButtonItem.makeKeyboardNavigationItems { prevButton, nextButton in + prevButton.target = self + prevButton.action = #selector(focusEmailTextField) + prevButton.isEnabled = canGoBackward + + nextButton.target = self + nextButton.action = #selector(focusDescriptionTextView) + nextButton.isEnabled = canGoForward + } + + toolbarItems.append(contentsOf: [ + UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil), + UIBarButtonItem( + barButtonSystemItem: .done, + target: self, + action: #selector(dismissKeyboard) + ), + ]) + + let toolbar = UIToolbar(frame: CGRect(x: 0, y: 0, width: 100, height: 44)) + toolbar.items = toolbarItems + return toolbar + } + + private func addConstraints() { + activeMessageTextViewConstraints = [ + messageTextView.topAnchor.constraint(equalTo: view.topAnchor), + messageTextView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + messageTextView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + messageTextView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ] + + inactiveMessageTextViewConstraints = [ + messageTextView.topAnchor.constraint( + equalTo: emailTextField.bottomAnchor, + constant: 12 + ), + messageTextView.leadingAnchor.constraint(equalTo: textFieldsHolder.leadingAnchor), + messageTextView.trailingAnchor.constraint(equalTo: textFieldsHolder.trailingAnchor), + messageTextView.bottomAnchor.constraint(equalTo: textFieldsHolder.bottomAnchor), + ] + + var constraints = [ + subheaderLabel.topAnchor + .constraint(equalTo: containerView.layoutMarginsGuide.topAnchor), + subheaderLabel.leadingAnchor + .constraint(equalTo: containerView.layoutMarginsGuide.leadingAnchor), + subheaderLabel.trailingAnchor + .constraint(equalTo: containerView.layoutMarginsGuide.trailingAnchor), + + textFieldsHolder.topAnchor.constraint( + equalTo: subheaderLabel.bottomAnchor, + constant: 24 + ), + textFieldsHolder.leadingAnchor + .constraint(equalTo: containerView.layoutMarginsGuide.leadingAnchor), + textFieldsHolder.trailingAnchor + .constraint(equalTo: containerView.layoutMarginsGuide.trailingAnchor), + + buttonsStackView.topAnchor.constraint( + equalTo: textFieldsHolder.bottomAnchor, + constant: 18 + ), + buttonsStackView.leadingAnchor + .constraint(equalTo: containerView.layoutMarginsGuide.leadingAnchor), + buttonsStackView.trailingAnchor + .constraint(equalTo: containerView.layoutMarginsGuide.trailingAnchor), + buttonsStackView.bottomAnchor + .constraint(equalTo: containerView.layoutMarginsGuide.bottomAnchor), + + emailTextField.topAnchor.constraint(equalTo: textFieldsHolder.topAnchor), + emailTextField.leadingAnchor.constraint(equalTo: textFieldsHolder.leadingAnchor), + emailTextField.trailingAnchor.constraint(equalTo: textFieldsHolder.trailingAnchor), + + messagePlaceholder.topAnchor.constraint( + equalTo: emailTextField.bottomAnchor, + constant: 12 + ), + messagePlaceholder.leadingAnchor.constraint(equalTo: textFieldsHolder.leadingAnchor), + messagePlaceholder.trailingAnchor.constraint(equalTo: textFieldsHolder.trailingAnchor), + messagePlaceholder.bottomAnchor.constraint(equalTo: textFieldsHolder.bottomAnchor), + messagePlaceholder.heightAnchor.constraint(equalTo: messageTextView.heightAnchor), + + scrollView.frameLayoutGuide.topAnchor.constraint(equalTo: view.topAnchor), + scrollView.frameLayoutGuide.bottomAnchor.constraint(equalTo: view.bottomAnchor), + scrollView.frameLayoutGuide.leadingAnchor.constraint(equalTo: view.leadingAnchor), + scrollView.frameLayoutGuide.trailingAnchor.constraint(equalTo: view.trailingAnchor), + + scrollView.contentLayoutGuide.topAnchor.constraint(equalTo: containerView.topAnchor), + scrollView.contentLayoutGuide.bottomAnchor + .constraint(equalTo: containerView.bottomAnchor), + scrollView.contentLayoutGuide.leadingAnchor + .constraint(equalTo: containerView.leadingAnchor), + scrollView.contentLayoutGuide.trailingAnchor + .constraint(equalTo: containerView.trailingAnchor), + + scrollView.contentLayoutGuide.widthAnchor + .constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor), + scrollView.contentLayoutGuide.heightAnchor + .constraint(greaterThanOrEqualTo: scrollView.safeAreaLayoutGuide.heightAnchor), + + messageTextView.heightAnchor.constraint(greaterThanOrEqualToConstant: 150), + ] + + constraints.append(contentsOf: inactiveMessageTextViewConstraints) + + NSLayoutConstraint.activate(constraints) + } + + private func setDescriptionFieldExpanded(_ isExpanded: Bool) { + // Make voice over ignore siblings when expanded + messageTextView.accessibilityViewIsModal = isExpanded + + if isExpanded { + // Disable the large title + navigationItem.largeTitleDisplayMode = .never + + // Move the text view above scroll view + view.addSubview(messageTextView) + + // Re-add old constraints + NSLayoutConstraint.activate(inactiveMessageTextViewConstraints) + + // Do a layout pass + view.layoutIfNeeded() + + // Swap constraints + NSLayoutConstraint.deactivate(inactiveMessageTextViewConstraints) + NSLayoutConstraint.activate(activeMessageTextViewConstraints) + + // Enable content inset adjustment on text view + messageTextView.contentInsetAdjustmentBehavior = .always + + // Animate constraints & rounded corners on the text view + animateDescriptionTextView(animations: { + // Turn off rounded corners as the text view fills in the entire view + self.messageTextView.roundCorners = false + + self.view.layoutIfNeeded() + }) { completed in + self.isMessageTextViewExpanded = true + + self.textViewKeyboardResponder?.updateContentInsets() + + // Tell accessibility engine to scan the new layout + UIAccessibility.post(notification: .layoutChanged, argument: nil) + } + + } else { + // Re-enable the large title + navigationItem.largeTitleDisplayMode = .automatic + + // Swap constraints + NSLayoutConstraint.deactivate(activeMessageTextViewConstraints) + NSLayoutConstraint.activate(inactiveMessageTextViewConstraints) + + // Animate constraints & rounded corners on the text view + animateDescriptionTextView(animations: { + // Turn on rounded corners as the text view returns back to where it was + self.messageTextView.roundCorners = true + + self.view.layoutIfNeeded() + }) { completed in + // Revert the content adjustment behavior + self.messageTextView.contentInsetAdjustmentBehavior = .never + + // Add the text view inside of the scroll view + self.textFieldsHolder.addSubview(self.messageTextView) + + self.isMessageTextViewExpanded = false + + // Tell accessibility engine to scan the new layout + UIAccessibility.post(notification: .layoutChanged, argument: nil) + } + } + } + + private func animateDescriptionTextView( + animations: @escaping () -> Void, + completion: @escaping (Bool) -> Void + ) { + UIView.animate(withDuration: 0.25, animations: animations) { completed in + completion(completed) + } + } + + private func presentEmptyEmailConfirmationAlert(completion: @escaping (Bool) -> Void) { + let message = NSLocalizedString( + "EMPTY_EMAIL_ALERT_MESSAGE", + tableName: "ProblemReport", + value: "You are about to send the problem report without a way for us to get back to you. If you want an answer to your report you will have to enter an email address.", + comment: "" + ) + + let alertController = UIAlertController( + title: nil, + message: message, + preferredStyle: .alert + ) + + let cancelAction = UIAlertAction( + title: NSLocalizedString( + "EMPTY_EMAIL_ALERT_CANCEL_ACTION", + tableName: "ProblemReport", + value: "Cancel", + comment: "" + ), + style: .cancel + ) { _ in + completion(false) + } + let sendAction = UIAlertAction( + title: NSLocalizedString( + "EMPTY_EMAIL_ALERT_SEND_ANYWAY_ACTION", + tableName: "ProblemReport", + value: "Send anyway", + comment: "" + ), + style: .destructive + ) { _ in + completion(true) + } + + alertController.addAction(cancelAction) + alertController.addAction(sendAction) + + present(alertController, animated: true) + } + + // MARK: - Private: Problem report submission + + private var showsSubmissionOverlay = false + + private func showSubmissionOverlay() { + guard !showsSubmissionOverlay else { return } + + showsSubmissionOverlay = true + + view.addSubview(submissionOverlayView) + + NSLayoutConstraint.activate([ + submissionOverlayView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + submissionOverlayView.leadingAnchor + .constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), + submissionOverlayView.trailingAnchor + .constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), + submissionOverlayView.bottomAnchor + .constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), + ]) + + UIView.transition( + from: scrollView, + to: submissionOverlayView, + duration: 0.25, + options: [.showHideTransitionViews, .transitionCrossDissolve] + ) { success in + // success + } + } + + private func hideSubmissionOverlay() { + guard showsSubmissionOverlay else { return } + + showsSubmissionOverlay = false + + UIView.transition( + from: submissionOverlayView, + to: scrollView, + duration: 0.25, + options: [.showHideTransitionViews, .transitionCrossDissolve] + ) { success in + // success + self.submissionOverlayView.removeFromSuperview() + } + } + + // MARK: - Data model + + private struct ViewModel { + let email: String + let message: String + + init() { + email = "" + message = "" + } + + init(email: String, message: String) { + self.email = email.trimmingCharacters(in: .whitespacesAndNewlines) + self.message = message.trimmingCharacters(in: .whitespacesAndNewlines) + } + + var isValid: Bool { + return !message.isEmpty + } + } + + private static var persistentViewModel = ViewModel() + + private func loadPersistentViewModel() { + emailTextField.text = Self.persistentViewModel.email + messageTextView.text = Self.persistentViewModel.message + + validateForm() + } + + private func updatePersistentViewModel() { + Self.persistentViewModel = ViewModel( + email: emailTextField.text ?? "", + message: messageTextView.text + ) + + validateForm() + } + + private func setPopGestureEnabled(_ isEnabled: Bool) { + navigationController?.interactivePopGestureRecognizer?.isEnabled = isEnabled + } + + private func clearPersistentViewModel() { + Self.persistentViewModel = ViewModel() + } + + // MARK: - Form validation + + private func validateForm() { + sendButton.isEnabled = Self.persistentViewModel.isValid + } + + // MARK: - Problem submission progress handling + + private func willSendProblemReport() { + showSubmissionOverlay() + + submissionOverlayView.state = .sending + navigationItem.setHidesBackButton(true, animated: true) + } + + private func didSendProblemReport( + viewModel: ViewModel, + completion: Result<Void, Error> + ) { + switch completion { + case .success: + submissionOverlayView.state = .sent(viewModel.email) + + // Clear persistent view model upon successful submission + clearPersistentViewModel() + + case let .failure(error): + submissionOverlayView.state = .failure(error) + } + + navigationItem.setHidesBackButton(false, animated: true) + } + + // MARK: - Problem report submission helpers + + private func sendProblemReport() { + let viewModel = Self.persistentViewModel + + willSendProblemReport() + + _ = interactor.sendReport( + email: viewModel.email, + message: viewModel.message + ) { completion in + self.didSendProblemReport(viewModel: viewModel, completion: completion) + } + } + + // MARK: - Input fields notifications + + @objc private func messageTextViewDidBeginEditing() { + setDescriptionFieldExpanded(true) + setPopGestureEnabled(false) + } + + @objc private func messageTextViewDidEndEditing() { + setDescriptionFieldExpanded(false) + setPopGestureEnabled(true) + } + + @objc private func messageTextViewDidChange() { + updatePersistentViewModel() + } + + @objc private func emailTextFieldDidChange() { + updatePersistentViewModel() + } + + // MARK: - UITextFieldDelegate + + func textFieldDidBeginEditing(_ textField: UITextField) { + setPopGestureEnabled(false) + } + + func textFieldDidEndEditing(_ textField: UITextField) { + setPopGestureEnabled(true) + } + + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + messageTextView.becomeFirstResponder() + return false + } +} |
