summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorBug Magnet <marco.nikic@mullvad.net>2024-02-09 11:26:27 +0100
committerBug Magnet <marco.nikic@mullvad.net>2024-02-12 15:01:16 +0100
commit387edd739b440fa81d95d2e6513f1b5ff17bbb34 (patch)
tree33f3f337dba3f55e54bafcaea3d6d7a1b9eb6ae0
parent1ad14a0f996d938233c121ffae783c135686cf8f (diff)
downloadmullvadvpn-387edd739b440fa81d95d2e6513f1b5ff17bbb34.tar.xz
mullvadvpn-387edd739b440fa81d95d2e6513f1b5ff17bbb34.zip
Reorder ProblemReportViewController and get rid of swiftlint warnings
-rw-r--r--ios/MullvadVPN.xcodeproj/project.pbxproj10
-rw-r--r--ios/MullvadVPN/View controllers/ProblemReport/ProblemReportViewController+ViewManagement.swift323
-rw-r--r--ios/MullvadVPN/View controllers/ProblemReport/ProblemReportViewController.swift463
-rw-r--r--ios/MullvadVPN/View controllers/ProblemReport/ProblemReportViewModel.swift101
-rw-r--r--ios/MullvadVPNTests/StartTunnelOperationTests.swift4
5 files changed, 472 insertions, 429 deletions
diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj
index c8d3db4fa1..e2ad8ebbcc 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 = "<group>"; };
A988DF252ADE86ED00D807EF /* WireGuardObfuscationSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WireGuardObfuscationSettings.swift; sourceTree = "<group>"; };
A988DF282ADE880300D807EF /* TunnelSettingsV3.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelSettingsV3.swift; sourceTree = "<group>"; };
+ A99E5EDF2B7628150033F241 /* ProblemReportViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProblemReportViewModel.swift; sourceTree = "<group>"; };
+ A99E5EE12B762ED30033F241 /* ProblemReportViewController+ViewManagement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProblemReportViewController+ViewManagement.swift"; sourceTree = "<group>"; };
A9A1DE782AD5708E0073F689 /* TransportStrategy.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TransportStrategy.swift; sourceTree = "<group>"; };
A9A5F9A12ACB003D0083449F /* TunnelManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelManagerTests.swift; sourceTree = "<group>"; };
A9A8A8EA2A262AB30086D569 /* FileCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileCache.swift; sourceTree = "<group>"; };
@@ -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 = "<group>";
@@ -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 0000000000..30a5e7d2e2
--- /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 1bbd63f101..45292672d3 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<Void, Error>
) {
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 0000000000..8fa1bf794c
--- /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 5dfd5d904f..6d5b032104 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()