diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index c8d3db4fa11b..e2ad8ebbcc14 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -648,6 +648,8 @@ A988A3E22AFE54AC0008D2C7 /* AccountExpiry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A6F2FA62AFBB9AE006D0856 /* AccountExpiry.swift */; }; A988DF272ADE86ED00D807EF /* WireGuardObfuscationSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A988DF252ADE86ED00D807EF /* WireGuardObfuscationSettings.swift */; }; A988DF2A2ADE880300D807EF /* TunnelSettingsV3.swift in Sources */ = {isa = PBXBuildFile; fileRef = A988DF282ADE880300D807EF /* TunnelSettingsV3.swift */; }; + A99E5EE02B7628150033F241 /* ProblemReportViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A99E5EDF2B7628150033F241 /* ProblemReportViewModel.swift */; }; + A99E5EE22B762ED30033F241 /* ProblemReportViewController+ViewManagement.swift in Sources */ = {isa = PBXBuildFile; fileRef = A99E5EE12B762ED30033F241 /* ProblemReportViewController+ViewManagement.swift */; }; A9A1DE792AD5708E0073F689 /* TransportStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9A1DE782AD5708E0073F689 /* TransportStrategy.swift */; }; A9A5F9E12ACB05160083449F /* AddressCacheTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06AC114028F841390037AF9A /* AddressCacheTracker.swift */; }; A9A5F9E22ACB05160083449F /* BackgroundTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58C76A0A2A338E4300100D75 /* BackgroundTask.swift */; }; @@ -1831,6 +1833,8 @@ A98502022B627B120061901E /* LocalNetworkProbe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalNetworkProbe.swift; sourceTree = ""; }; A988DF252ADE86ED00D807EF /* WireGuardObfuscationSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WireGuardObfuscationSettings.swift; sourceTree = ""; }; A988DF282ADE880300D807EF /* TunnelSettingsV3.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelSettingsV3.swift; sourceTree = ""; }; + A99E5EDF2B7628150033F241 /* ProblemReportViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProblemReportViewModel.swift; sourceTree = ""; }; + A99E5EE12B762ED30033F241 /* ProblemReportViewController+ViewManagement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProblemReportViewController+ViewManagement.swift"; sourceTree = ""; }; A9A1DE782AD5708E0073F689 /* TransportStrategy.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TransportStrategy.swift; sourceTree = ""; }; A9A5F9A12ACB003D0083449F /* TunnelManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelManagerTests.swift; sourceTree = ""; }; A9A8A8EA2A262AB30086D569 /* FileCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileCache.swift; sourceTree = ""; }; @@ -2311,10 +2315,12 @@ 583FE01929C19760006E85F9 /* ProblemReport */ = { isa = PBXGroup; children = ( + 5878A26E2907E7E00096FC88 /* ProblemReportInteractor.swift */, 58F8AC0D25D3F8CE002BE0ED /* ProblemReportReviewViewController.swift */, 58EF580A25D69D7A00AEBA94 /* ProblemReportSubmissionOverlayView.swift */, 58293FAC2510CA58005D0BB5 /* ProblemReportViewController.swift */, - 5878A26E2907E7E00096FC88 /* ProblemReportInteractor.swift */, + A99E5EE12B762ED30033F241 /* ProblemReportViewController+ViewManagement.swift */, + A99E5EDF2B7628150033F241 /* ProblemReportViewModel.swift */, ); path = ProblemReport; sourceTree = ""; @@ -5098,9 +5104,11 @@ 7A9CCCB62A96302800DD6A34 /* OutOfTimeCoordinator.swift in Sources */, 5827B0AA2B0F4C9100CCBBA1 /* EditAccessMethodViewControllerDelegate.swift in Sources */, 7A5869A82B5140C200640D27 /* MethodSettingsValidationErrorContentView.swift in Sources */, + A99E5EE22B762ED30033F241 /* ProblemReportViewController+ViewManagement.swift in Sources */, 7A5869A22B502EA800640D27 /* MethodSettingsSectionIdentifier.swift in Sources */, 586C0D812B03CA8400E7CDD7 /* CurrentValueSubject+UIActionBindings.swift in Sources */, 581DFAEA2B176C51005D6D1C /* PersistentProxyConfiguration+ViewModel.swift in Sources */, + A99E5EE02B7628150033F241 /* ProblemReportViewModel.swift in Sources */, 58FD5BF024238EB300112C88 /* SKProduct+Formatting.swift in Sources */, 58B43C1925F77DB60002C8C3 /* TunnelControlView.swift in Sources */, F09A297B2A9F8A9B00EA3B6F /* LogoutDialogueView.swift in Sources */, diff --git a/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportViewController+ViewManagement.swift b/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportViewController+ViewManagement.swift new file mode 100644 index 000000000000..30a5e7d2e276 --- /dev/null +++ b/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportViewController+ViewManagement.swift @@ -0,0 +1,323 @@ +// +// ProblemReportViewController+ViewManagement.swift +// MullvadVPN +// +// Created by Marco Nikic on 2024-02-09. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import UIKit + +extension ProblemReportViewController { + func makeScrollView() -> UIScrollView { + let scrollView = UIScrollView() + scrollView.translatesAutoresizingMaskIntoConstraints = false + scrollView.backgroundColor = .clear + return scrollView + } + + func makeContainerView() -> UIView { + let containerView = UIView() + containerView.translatesAutoresizingMaskIntoConstraints = false + containerView.directionalLayoutMargins = UIMetrics.contentLayoutMargins + containerView.backgroundColor = .clear + return containerView + } + + func makeSubheaderLabel() -> UILabel { + let textLabel = UILabel() + textLabel.translatesAutoresizingMaskIntoConstraints = false + textLabel.numberOfLines = 0 + textLabel.textColor = .white + textLabel.text = Self.persistentViewModel.subheadLabelText + return textLabel + } + + func makeEmailTextField() -> 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 = Self.persistentViewModel.emailPlaceholderText + return textField + } + + func makeMessageTextView() -> CustomTextView { + let textView = CustomTextView() + textView.translatesAutoresizingMaskIntoConstraints = false + textView.backgroundColor = .white + textView.inputAccessoryView = messageAccessoryToolbar + textView.font = UIFont.systemFont(ofSize: 17) + textView.placeholder = Self.persistentViewModel.messageTextViewPlaceholder + textView.contentInsetAdjustmentBehavior = .never + + return textView + } + + func makeTextFieldsHolder() -> UIView { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + return view + } + + func makeMessagePlaceholderView() -> UIView { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + view.backgroundColor = .clear + return view + } + + func makeButtonsStackView() -> UIStackView { + let stackView = UIStackView(arrangedSubviews: [self.viewLogsButton, self.sendButton]) + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .vertical + stackView.spacing = 18 + + return stackView + } + + func makeViewLogsButton() -> AppButton { + let button = AppButton(style: .default) + button.translatesAutoresizingMaskIntoConstraints = false + button.setTitle(Self.persistentViewModel.viewLogsButtonTitle, for: .normal) + button.addTarget(self, action: #selector(handleViewLogsButtonTap), for: .touchUpInside) + return button + } + + func makeSendButton() -> AppButton { + let button = AppButton(style: .success) + button.translatesAutoresizingMaskIntoConstraints = false + button.setTitle(Self.persistentViewModel.sendLogsButtonTitle, for: .normal) + button.addTarget(self, action: #selector(handleSendButtonTap), for: .touchUpInside) + return button + } + + func makeSubmissionOverlayView() -> ProblemReportSubmissionOverlayView { + let overlay = ProblemReportSubmissionOverlayView() + overlay.translatesAutoresizingMaskIntoConstraints = false + + overlay.editButtonAction = { [weak self] in + self?.hideSubmissionOverlay() + } + + overlay.retryButtonAction = { [weak self] in + self?.sendProblemReport() + } + + return overlay + } + + func addConstraints() { + activeMessageTextViewConstraints = + messageTextView.pinEdges(.all().excluding(.top), to: view) + + messageTextView.pinEdges(PinnableEdges([.top(0)]), to: view.safeAreaLayoutGuide) + + inactiveMessageTextViewConstraints = + messageTextView.pinEdges(.all().excluding(.top), to: textFieldsHolder) + + [messageTextView.topAnchor.constraint(equalTo: emailTextField.bottomAnchor, constant: 12)] + + textFieldsHolder.addSubview(emailTextField) + textFieldsHolder.addSubview(messagePlaceholder) + textFieldsHolder.addSubview(messageTextView) + + scrollView.addSubview(containerView) + containerView.addSubview(subheaderLabel) + containerView.addSubview(textFieldsHolder) + containerView.addSubview(buttonsStackView) + + view.addConstrainedSubviews([scrollView]) { + inactiveMessageTextViewConstraints + + subheaderLabel.pinEdges(.all().excluding(.bottom), to: containerView.layoutMarginsGuide) + + textFieldsHolder.pinEdges(PinnableEdges([.leading(0), .trailing(0)]), to: containerView.layoutMarginsGuide) + textFieldsHolder.topAnchor.constraint(equalTo: subheaderLabel.bottomAnchor, constant: 24) + + buttonsStackView.pinEdges(.all().excluding(.top), to: containerView.layoutMarginsGuide) + buttonsStackView.topAnchor.constraint(equalTo: textFieldsHolder.bottomAnchor, constant: 18) + + emailTextField.pinEdges(.all().excluding(.bottom), to: textFieldsHolder) + + messagePlaceholder.pinEdges(.all().excluding(.top), to: textFieldsHolder) + messagePlaceholder.topAnchor.constraint(equalTo: emailTextField.bottomAnchor, constant: 12) + 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) + } + } + + override func viewSafeAreaInsetsDidChange() { + super.viewSafeAreaInsetsDidChange() + + scrollViewKeyboardResponder?.updateContentInsets() + textViewKeyboardResponder?.updateContentInsets() + } + + 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 + } + + 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() + }, completion: { _ 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() + }, completion: { _ 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) + }) + } + } + + func animateDescriptionTextView( + animations: @escaping () -> Void, + completion: @escaping (Bool) -> Void + ) { + UIView.animate(withDuration: 0.25, animations: animations) { completed in + completion(completed) + } + } + + 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] + ) { _ in + // success + } + } + + func hideSubmissionOverlay() { + guard showsSubmissionOverlay else { return } + + showsSubmissionOverlay = false + + UIView.transition( + from: submissionOverlayView, + to: scrollView, + duration: 0.25, + options: [.showHideTransitionViews, .transitionCrossDissolve] + ) { _ in + // success + self.submissionOverlayView.removeFromSuperview() + } + } +} diff --git a/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportViewController.swift b/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportViewController.swift index 1bbd63f101a3..45292672d337 100644 --- a/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportViewController.swift +++ b/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportViewController.swift @@ -15,185 +15,52 @@ final class ProblemReportViewController: UIViewController, UITextFieldDelegate { private let interactor: ProblemReportInteractor private let alertPresenter: AlertPresenter - 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.directionalLayoutMargins = 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: """ - To assist you better, please write in English or Swedish and \ - include which country you are connecting from. - """, - 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 - }() + var textViewKeyboardResponder: AutomaticKeyboardResponder? + var scrollViewKeyboardResponder: AutomaticKeyboardResponder? + var showsSubmissionOverlay = false /// Constraints used when description text view is active - private var activeMessageTextViewConstraints = [NSLayoutConstraint]() - + var activeMessageTextViewConstraints = [NSLayoutConstraint]() /// Constraints used when description text view is inactive - private var inactiveMessageTextViewConstraints = [NSLayoutConstraint]() - + var inactiveMessageTextViewConstraints = [NSLayoutConstraint]() /// Flag indicating when the text view is expanded to fill the entire view - private var isMessageTextViewExpanded = false + var isMessageTextViewExpanded = false + static var persistentViewModel = ProblemReportViewModel() + + /// Scroll view + lazy var scrollView: UIScrollView = { makeScrollView() }() + /// Scroll view content container + lazy var containerView: UIView = { makeContainerView() }() + /// Subheading label displayed below navigation bar + lazy var subheaderLabel: UILabel = { makeSubheaderLabel() }() + lazy var emailTextField: CustomTextField = { makeEmailTextField() }() + lazy var messageTextView: CustomTextView = { makeMessageTextView() }() + /// Container view for text input fields + lazy var textFieldsHolder: UIView = { makeTextFieldsHolder() }() /// 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 - }() - + lazy var messagePlaceholder: UIView = { makeMessagePlaceholderView() }() /// 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( + lazy var buttonsStackView: UIStackView = { makeButtonsStackView() }() + lazy var viewLogsButton: AppButton = { makeViewLogsButton() }() + lazy var sendButton: AppButton = { makeSendButton() }() + lazy var emailAccessoryToolbar: UIToolbar = makeKeyboardToolbar( canGoBackward: false, canGoForward: true ) - - private lazy var messageAccessoryToolbar: UIToolbar = makeKeyboardToolbar( + 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 - }() + lazy var submissionOverlayView: ProblemReportSubmissionOverlayView = { makeSubmissionOverlayView() }() // MARK: - View lifecycle - override var preferredStatusBarStyle: UIStatusBarStyle { - .lightContent - } - - override var disablesAutomaticKeyboardDismissal: Bool { - // Allow dismissing the keyboard in .formSheet presentation style - false - } + override var preferredStatusBarStyle: UIStatusBarStyle { .lightContent } + // Allow dismissing the keyboard in .formSheet presentation style + override var disablesAutomaticKeyboardDismissal: Bool { false } init(interactor: ProblemReportInteractor, alertPresenter: AlertPresenter) { self.interactor = interactor @@ -202,21 +69,14 @@ final class ProblemReportViewController: UIViewController, UITextFieldDelegate { super.init(nibName: nil, bundle: nil) } - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } + 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: "" - ) + navigationItem.title = Self.persistentViewModel.navigationTitle textViewKeyboardResponder = AutomaticKeyboardResponder(targetView: messageTextView) scrollViewKeyboardResponder = AutomaticKeyboardResponder(targetView: scrollView) @@ -235,13 +95,6 @@ final class ProblemReportViewController: UIViewController, UITextFieldDelegate { loadPersistentViewModel() } - override func viewSafeAreaInsetsDidChange() { - super.viewSafeAreaInsetsDidChange() - - scrollViewKeyboardResponder?.updateContentInsets() - textViewKeyboardResponder?.updateContentInsets() - } - // MARK: - Actions @objc func focusEmailTextField() { @@ -311,193 +164,21 @@ final class ProblemReportViewController: UIViewController, UITextFieldDelegate { ) } - 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.pinEdges(.all().excluding(.top), to: view) + - messageTextView.pinEdges(PinnableEdges([.top(0)]), to: view.safeAreaLayoutGuide) - - inactiveMessageTextViewConstraints = - messageTextView.pinEdges(.all().excluding(.top), to: textFieldsHolder) + - [messageTextView.topAnchor.constraint(equalTo: emailTextField.bottomAnchor, constant: 12)] - - textFieldsHolder.addSubview(emailTextField) - textFieldsHolder.addSubview(messagePlaceholder) - textFieldsHolder.addSubview(messageTextView) - - scrollView.addSubview(containerView) - containerView.addSubview(subheaderLabel) - containerView.addSubview(textFieldsHolder) - containerView.addSubview(buttonsStackView) - - view.addConstrainedSubviews([scrollView]) { - inactiveMessageTextViewConstraints - - subheaderLabel.pinEdges(.all().excluding(.bottom), to: containerView.layoutMarginsGuide) - - textFieldsHolder.pinEdges(PinnableEdges([.leading(0), .trailing(0)]), to: containerView.layoutMarginsGuide) - textFieldsHolder.topAnchor.constraint(equalTo: subheaderLabel.bottomAnchor, constant: 24) - - buttonsStackView.pinEdges(.all().excluding(.top), to: containerView.layoutMarginsGuide) - buttonsStackView.topAnchor.constraint(equalTo: textFieldsHolder.bottomAnchor, constant: 18) - - emailTextField.pinEdges(.all().excluding(.bottom), to: textFieldsHolder) - - messagePlaceholder.pinEdges(.all().excluding(.top), to: textFieldsHolder) - messagePlaceholder.topAnchor.constraint(equalTo: emailTextField.bottomAnchor, constant: 12) - 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) - } - } - - 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() - }, completion: { _ 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() - }, completion: { _ 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 presentation = AlertPresentation( id: "problem-report-alert", icon: .alert, - 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: "" - ), + message: Self.persistentViewModel.emptyEmailAlertWarning, buttons: [ AlertAction( - title: NSLocalizedString( - "EMPTY_EMAIL_ALERT_SEND_ANYWAY_ACTION", - tableName: "ProblemReport", - value: "Send anyway", - comment: "" - ), + title: Self.persistentViewModel.confirmEmptyEmailTitle, style: .destructive, handler: { completion(true) } ), AlertAction( - title: NSLocalizedString( - "EMPTY_EMAIL_ALERT_CANCEL_ACTION", - tableName: "ProblemReport", - value: "Cancel", - comment: "" - ), + title: Self.persistentViewModel.cancelEmptyEmailTitle, style: .default, handler: { completion(false) @@ -509,76 +190,8 @@ final class ProblemReportViewController: UIViewController, UITextFieldDelegate { alertPresenter.showAlert(presentation: presentation, 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] - ) { _ in - // success - } - } - - private func hideSubmissionOverlay() { - guard showsSubmissionOverlay else { return } - - showsSubmissionOverlay = false - - UIView.transition( - from: submissionOverlayView, - to: scrollView, - duration: 0.25, - options: [.showHideTransitionViews, .transitionCrossDissolve] - ) { _ 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 { - !message.isEmpty - } - } - - private static var persistentViewModel = ViewModel() - private func loadPersistentViewModel() { emailTextField.text = Self.persistentViewModel.email messageTextView.text = Self.persistentViewModel.message @@ -587,7 +200,7 @@ final class ProblemReportViewController: UIViewController, UITextFieldDelegate { } private func updatePersistentViewModel() { - Self.persistentViewModel = ViewModel( + Self.persistentViewModel = ProblemReportViewModel( email: emailTextField.text ?? "", message: messageTextView.text ) @@ -600,7 +213,7 @@ final class ProblemReportViewController: UIViewController, UITextFieldDelegate { } private func clearPersistentViewModel() { - Self.persistentViewModel = ViewModel() + Self.persistentViewModel = ProblemReportViewModel() } // MARK: - Form validation @@ -619,7 +232,7 @@ final class ProblemReportViewController: UIViewController, UITextFieldDelegate { } private func didSendProblemReport( - viewModel: ViewModel, + viewModel: ProblemReportViewModel, completion: Result ) { switch completion { @@ -638,7 +251,7 @@ final class ProblemReportViewController: UIViewController, UITextFieldDelegate { // MARK: - Problem report submission helpers - private func sendProblemReport() { + func sendProblemReport() { let viewModel = Self.persistentViewModel willSendProblemReport() @@ -685,6 +298,4 @@ final class ProblemReportViewController: UIViewController, UITextFieldDelegate { messageTextView.becomeFirstResponder() return false } - - // swiftlint:disable:next file_length } diff --git a/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportViewModel.swift b/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportViewModel.swift new file mode 100644 index 000000000000..8fa1bf794c06 --- /dev/null +++ b/ios/MullvadVPN/View controllers/ProblemReport/ProblemReportViewModel.swift @@ -0,0 +1,101 @@ +// +// ProblemReportViewModel.swift +// MullvadVPN +// +// Created by Marco Nikic on 2024-02-09. +// Copyright © 2024 Mullvad VPN AB. All rights reserved. +// + +import Foundation + +struct ProblemReportViewModel { + let email: String + let message: String + + let navigationTitle = NSLocalizedString( + "NAVIGATION_TITLE", + tableName: "ProblemReport", + value: "Report a problem", + comment: "" + ) + + let subheadLabelText = 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: "" + ) + + let emailPlaceholderText = NSLocalizedString( + "EMAIL_TEXTFIELD_PLACEHOLDER", + tableName: "ProblemReport", + value: "Your email (optional)", + comment: "" + ) + + let messageTextViewPlaceholder = NSLocalizedString( + "DESCRIPTION_TEXTVIEW_PLACEHOLDER", + tableName: "ProblemReport", + value: """ + To assist you better, please write in English or Swedish and \ + include which country you are connecting from. + """, + comment: "" + ) + + let viewLogsButtonTitle = NSLocalizedString( + "VIEW_APP_LOGS_BUTTON_TITLE", + tableName: "ProblemReport", + value: "View app logs", + comment: "" + ) + + let sendLogsButtonTitle = NSLocalizedString( + "SEND_BUTTON_TITLE", + tableName: "ProblemReport", + value: "Send", + comment: "" + ) + + let emptyEmailAlertWarning = 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 confirmEmptyEmailTitle = NSLocalizedString( + "EMPTY_EMAIL_ALERT_SEND_ANYWAY_ACTION", + tableName: "ProblemReport", + value: "Send anyway", + comment: "" + ) + + let cancelEmptyEmailTitle = NSLocalizedString( + "EMPTY_EMAIL_ALERT_CANCEL_ACTION", + tableName: "ProblemReport", + value: "Cancel", + comment: "" + ) + + init() { + email = "" + message = "" + } + + init(email: String, message: String) { + self.email = email.trimmingCharacters(in: .whitespacesAndNewlines) + self.message = message.trimmingCharacters(in: .whitespacesAndNewlines) + } + + var isValid: Bool { + !message.isEmpty + } +} diff --git a/ios/MullvadVPNTests/StartTunnelOperationTests.swift b/ios/MullvadVPNTests/StartTunnelOperationTests.swift index 5dfd5d904f17..6d5b032104b7 100644 --- a/ios/MullvadVPNTests/StartTunnelOperationTests.swift +++ b/ios/MullvadVPNTests/StartTunnelOperationTests.swift @@ -75,7 +75,7 @@ class StartTunnelOperationTests: XCTestCase { let operation = StartTunnelOperation( dispatchQueue: testQueue, interactor: interactor - ) { result in + ) { _ in XCTAssertEqual(tunnelStatus.state, .disconnecting(.reconnect)) expectation.fulfill() } @@ -89,7 +89,7 @@ class StartTunnelOperationTests: XCTestCase { let operation = StartTunnelOperation( dispatchQueue: testQueue, interactor: interactor - ) { result in + ) { _ in XCTAssertNotNil(interactor.tunnel) XCTAssertNotNil(interactor.tunnel?.startDate) expectation.fulfill()