diff --git a/Samples/iOS-Swift/iOS-Swift-UITests/UserFeedbackUITests.swift b/Samples/iOS-Swift/iOS-Swift-UITests/UserFeedbackUITests.swift new file mode 100644 index 00000000000..5f95d05f34f --- /dev/null +++ b/Samples/iOS-Swift/iOS-Swift-UITests/UserFeedbackUITests.swift @@ -0,0 +1,178 @@ +//swiftlint:disable todo + +import XCTest + +class UserFeedbackUITests: BaseUITest { + override var automaticallyLaunchAndTerminateApp: Bool { false } + + override func setUp() { + super.setUp() + app.launchArguments.append(contentsOf: [ + "--io.sentry.iOS-Swift.auto-inject-user-feedback-widget", + "--io.sentry.iOS-Swift.user-feedback.all-defaults", + "--io.sentry.feedback.no-animations" + ]) + launchApp() + } + + func testSubmitFullyFilledForm() throws { + widgetButton.tap() + + nameField.tap() + nameField.typeText("Andrew") + + emailField.tap() + emailField.typeText("andrew.mcknight@sentry.io") + + messageTextView.tap() + messageTextView.typeText("UITest user feedback") + + app.staticTexts["Send Bug Report"].tap() + + // displaying the form again ensures the widget button still works afterwards; also assert that the fields are in their default state to ensure the entered data is not persisted between displays + + widgetButton.tap() + + // the placeholder text is returned for XCUIElement.value + XCTAssertEqual(try XCTUnwrap(nameField.value as? String), "Your Name") + XCTAssertEqual(try XCTUnwrap(emailField.value as? String), "your.email@example.org") + + // the UITextView doesn't hav a placeholder, it's a label on top of it. so it is actually empty + XCTAssertEqual(try XCTUnwrap(messageTextView.value as? String), "") + } + + func testSubmitWithNoFieldsFilled() throws { + widgetButton.tap() + + app.staticTexts["Send Bug Report"].tap() + + XCTAssert(app.staticTexts["Error"].exists) + + app.buttons["OK"].tap() + } + + func testSubmitWithOnlyRequiredFieldsFilled() { + widgetButton.tap() + + messageTextView.tap() + messageTextView.typeText("UITest user feedback") + + app.staticTexts["Send Bug Report"].tap() + + XCTAssert(widgetButton.waitForExistence(timeout: 1)) + } + + func testSubmitOnlyWithOptionalFieldsFilled() throws { + widgetButton.tap() + + nameField.tap() + nameField.typeText("Andrew") + + emailField.tap() + emailField.typeText("andrew.mcknight@sentry.io") + + app.staticTexts["Send Bug Report"].tap() + + XCTAssert(app.staticTexts["Error"].exists) + + app.buttons["OK"].tap() + } + + func testCancelFromFormByButton() { + widgetButton.tap() + + // fill out the fields; we'll assert later that the entered data does not reappear on subsequent displays + nameField.tap() + nameField.typeText("Andrew") + + emailField.tap() + emailField.typeText("andrew.mcknight@sentry.io") + + messageTextView.tap() + messageTextView.typeText("UITest user feedback") + + let cancelButton: XCUIElement = app.staticTexts["Cancel"] + cancelButton.tap() + + // displaying the form again ensures the widget button still works afterwards; also assert that the fields are in their default state to ensure the entered data is not persisted between displays + + widgetButton.tap() + + // the placeholder text is returned for XCUIElement.value + XCTAssertEqual(try XCTUnwrap(nameField.value as? String), "Your Name") + XCTAssertEqual(try XCTUnwrap(emailField.value as? String), "your.email@example.org") + + // the UITextView doesn't hav a placeholder, it's a label on top of it. so it is actually empty + XCTAssertEqual(try XCTUnwrap(messageTextView.value as? String), "") + } + + func testCancelFromFormBySwipeDown() { + widgetButton.tap() + + // fill out the fields; we'll assert later that the entered data does not reappear on subsequent displays + nameField.tap() + nameField.typeText("Andrew") + + emailField.tap() + emailField.typeText("andrew.mcknight@sentry.io") + + messageTextView.tap() + messageTextView.typeText("UITest user feedback") + + // the cancel gesture + app.swipeDown(velocity: .fast) + app.swipeDown(velocity: .fast) + + // the swipe dismiss animation takes an extra moment, so we need to wait for the widget to be visible again + XCTAssert(widgetButton.waitForExistence(timeout: 1)) + + // displaying the form again ensures the widget button still works afterwards; also assert that the fields are in their default state to ensure the entered data is not persisted between displays + + widgetButton.tap() + + // the placeholder text is returned for XCUIElement.value + XCTAssertEqual(try XCTUnwrap(nameField.value as? String), "Your Name") + XCTAssertEqual(try XCTUnwrap(emailField.value as? String), "your.email@example.org") + + // the UITextView doesn't hav a placeholder, it's a label on top of it. so it is actually empty + XCTAssertEqual(try XCTUnwrap(messageTextView.value as? String), "") + } + + func testAddingAndRemovingScreenshots() { + widgetButton.tap() + addScreenshotButton.tap() + XCTAssert(removeScreenshotButton.isHittable) + XCTAssertFalse(addScreenshotButton.isHittable) + removeScreenshotButton.tap() + XCTAssert(addScreenshotButton.isHittable) + XCTAssertFalse(removeScreenshotButton.isHittable) + } + + // MARK: Private + + var widgetButton: XCUIElement { + app.otherElements["io.sentry.feedback.widget"] + } + + var nameField: XCUIElement { + app.textFields["io.sentry.feedback.form.name"] + } + + var emailField: XCUIElement { + app.textFields["io.sentry.feedback.form.email"] + } + + var messageTextView: XCUIElement { + app.textViews["io.sentry.feedback.form.message"] + } + + var addScreenshotButton: XCUIElement { + app.buttons["io.sentry.feedback.form.add-screenshot"] + } + + var removeScreenshotButton: XCUIElement { + app.buttons["io.sentry.feedback.form.remove-screenshot"] + } +} + +//swiftlint:enable todo diff --git a/Samples/iOS-Swift/iOS-Swift.xcodeproj/project.pbxproj b/Samples/iOS-Swift/iOS-Swift.xcodeproj/project.pbxproj index 8d74641a75a..88802079bd7 100644 --- a/Samples/iOS-Swift/iOS-Swift.xcodeproj/project.pbxproj +++ b/Samples/iOS-Swift/iOS-Swift.xcodeproj/project.pbxproj @@ -43,6 +43,7 @@ 84BA72DE2C9391920045B828 /* GitInjections.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84BA72A52C93698E0045B828 /* GitInjections.swift */; }; 84BE546F287503F100ACC735 /* SentrySDKPerformanceBenchmarkTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 84BE546E287503F100ACC735 /* SentrySDKPerformanceBenchmarkTests.m */; }; 84BE547E287645B900ACC735 /* SentryProcessInfo.m in Sources */ = {isa = PBXBuildFile; fileRef = 84BE54792876451D00ACC735 /* SentryProcessInfo.m */; }; + 84DBC6252CE6D321000C4904 /* UserFeedbackUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84DBC61F2CE6D31C000C4904 /* UserFeedbackUITests.swift */; }; 84FB812A284001B800F3A94A /* SentryBenchmarking.mm in Sources */ = {isa = PBXBuildFile; fileRef = 84FB8129284001B800F3A94A /* SentryBenchmarking.mm */; }; 84FB812B284001B800F3A94A /* SentryBenchmarking.mm in Sources */ = {isa = PBXBuildFile; fileRef = 84FB8129284001B800F3A94A /* SentryBenchmarking.mm */; }; 8E8C57AF25EF16E6001CEEFA /* TraceTestViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E8C57AE25EF16E6001CEEFA /* TraceTestViewController.swift */; }; @@ -288,6 +289,7 @@ 84BE546E287503F100ACC735 /* SentrySDKPerformanceBenchmarkTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SentrySDKPerformanceBenchmarkTests.m; sourceTree = ""; }; 84BE54782876451D00ACC735 /* SentryProcessInfo.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = SentryProcessInfo.h; sourceTree = ""; }; 84BE54792876451D00ACC735 /* SentryProcessInfo.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SentryProcessInfo.m; sourceTree = ""; }; + 84DBC61F2CE6D31C000C4904 /* UserFeedbackUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserFeedbackUITests.swift; sourceTree = ""; }; 84FB8125284001B800F3A94A /* SentryBenchmarking.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = SentryBenchmarking.h; sourceTree = ""; }; 84FB8129284001B800F3A94A /* SentryBenchmarking.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = SentryBenchmarking.mm; sourceTree = ""; }; 84FB812C2840021B00F3A94A /* iOS-Swift-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "iOS-Swift-Bridging-Header.h"; sourceTree = ""; }; @@ -505,6 +507,7 @@ D8C33E2529FBB8D90071B75A /* UIEventBreadcrumbTests.swift */, 84A5D72C29D2708D00388BFA /* UITestHelpers.swift */, 84A5D72529D2705000388BFA /* ProfilingUITests.swift */, + 84DBC61F2CE6D31C000C4904 /* UserFeedbackUITests.swift */, 84B527B728DD24BA00475E8D /* SentryDeviceTests.mm */, 84B527BB28DD25E400475E8D /* SentryDevice.h */, 84B527BC28DD25E400475E8D /* SentryDevice.mm */, @@ -1144,6 +1147,7 @@ 62C07D5C2AF3E3F500894688 /* BaseUITest.swift in Sources */, 84A5D72629D2705000388BFA /* ProfilingUITests.swift in Sources */, 84A5D72D29D2708D00388BFA /* UITestHelpers.swift in Sources */, + 84DBC6252CE6D321000C4904 /* UserFeedbackUITests.swift in Sources */, 84B527B928DD24BA00475E8D /* SentryDeviceTests.mm in Sources */, 7B64386B26A6C544000D0F65 /* LaunchUITests.swift in Sources */, 84B527BD28DD25E400475E8D /* SentryDevice.mm in Sources */, @@ -1657,6 +1661,7 @@ MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = TEST; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; VALIDATE_PRODUCT = YES; @@ -1895,6 +1900,7 @@ MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = TESTCI; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; VALIDATE_PRODUCT = YES; diff --git a/Samples/iOS-Swift/iOS-Swift.xcodeproj/xcshareddata/xcschemes/iOS-Swift.xcscheme b/Samples/iOS-Swift/iOS-Swift.xcodeproj/xcshareddata/xcschemes/iOS-Swift.xcscheme index 1a8ce8b50e7..6924b480e57 100644 --- a/Samples/iOS-Swift/iOS-Swift.xcodeproj/xcshareddata/xcschemes/iOS-Swift.xcscheme +++ b/Samples/iOS-Swift/iOS-Swift.xcodeproj/xcshareddata/xcschemes/iOS-Swift.xcscheme @@ -73,6 +73,10 @@ argument = "--disable-file-io-tracing" isEnabled = "NO"> + + diff --git a/Samples/iOS-Swift/iOS-Swift/AppDelegate.swift b/Samples/iOS-Swift/iOS-Swift/AppDelegate.swift index 570b7b22455..3d9e54c6486 100644 --- a/Samples/iOS-Swift/iOS-Swift/AppDelegate.swift +++ b/Samples/iOS-Swift/iOS-Swift/AppDelegate.swift @@ -171,6 +171,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } return } + config.animations = !args.contains("--io.sentry.feedback.no-animations") config.useShakeGesture = true config.showFormForScreenshots = true config.configureWidget = { widget in @@ -200,6 +201,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } config.configureForm = { uiForm in uiForm.formTitle = "Jank Report" + uiForm.isEmailRequired = true uiForm.submitButtonLabel = "Report that jank" uiForm.addScreenshotButtonLabel = "Show us the jank" uiForm.messagePlaceholder = "Describe the nature of the jank. Its essence, if you will." diff --git a/Sources/Swift/Integrations/UserFeedback/Configuration/SentryUserFeedbackConfiguration.swift b/Sources/Swift/Integrations/UserFeedback/Configuration/SentryUserFeedbackConfiguration.swift index 102f682df70..51b0d8cdf54 100644 --- a/Sources/Swift/Integrations/UserFeedback/Configuration/SentryUserFeedbackConfiguration.swift +++ b/Sources/Swift/Integrations/UserFeedback/Configuration/SentryUserFeedbackConfiguration.swift @@ -10,6 +10,12 @@ import UIKit @available(iOS 13.0, *) @objcMembers public class SentryUserFeedbackConfiguration: NSObject { + /** + * Whether or not to show animations, like for presenting and dismissing the form. + * - note: Default: `true`. + */ + public var animations: Bool = true + /** * Configuration settings specific to the managed widget that displays the UI form. * - note: Default: `nil` to use the default widget settings. diff --git a/Sources/Swift/Integrations/UserFeedback/Configuration/SentryUserFeedbackFormConfiguration.swift b/Sources/Swift/Integrations/UserFeedback/Configuration/SentryUserFeedbackFormConfiguration.swift index fa1b2a81a65..65012aa19c3 100644 --- a/Sources/Swift/Integrations/UserFeedback/Configuration/SentryUserFeedbackFormConfiguration.swift +++ b/Sources/Swift/Integrations/UserFeedback/Configuration/SentryUserFeedbackFormConfiguration.swift @@ -63,6 +63,7 @@ public class SentryUserFeedbackFormConfiguration: NSObject { * The label of the button to add a screenshot to the form. * - note: Default: `"Add a screenshot"` * - note: ignored if `enableScreenshot` is `false`.` + * - warning: If you support adding screenshots using the button, you need to add `NSPhotoLibraryUsageDescription` to your app's Info.plist. */ public var addScreenshotButtonLabel: String = "Add a screenshot" diff --git a/Sources/Swift/Integrations/UserFeedback/Configuration/SentryUserFeedbackWidgetConfiguration.swift b/Sources/Swift/Integrations/UserFeedback/Configuration/SentryUserFeedbackWidgetConfiguration.swift index 8cdee46e2cd..11c5bd1e665 100644 --- a/Sources/Swift/Integrations/UserFeedback/Configuration/SentryUserFeedbackWidgetConfiguration.swift +++ b/Sources/Swift/Integrations/UserFeedback/Configuration/SentryUserFeedbackWidgetConfiguration.swift @@ -17,12 +17,6 @@ public class SentryUserFeedbackWidgetConfiguration: NSObject { */ public var autoInject: Bool = true - /** - * Whether or not to show animations, like for presenting and dismissing the form. - * - note: Default: `true`. - */ - public var animations: Bool = true - let defaultLabelText = "Report a Bug" /** diff --git a/Sources/Swift/Integrations/UserFeedback/SentryUserFeedbackForm.swift b/Sources/Swift/Integrations/UserFeedback/SentryUserFeedbackForm.swift index 42a83f23af0..72c6b55fcc3 100644 --- a/Sources/Swift/Integrations/UserFeedback/SentryUserFeedbackForm.swift +++ b/Sources/Swift/Integrations/UserFeedback/SentryUserFeedbackForm.swift @@ -1,6 +1,9 @@ +//swiftlint:disable todo type_body_length file_length + import Foundation #if os(iOS) && !SENTRY_NO_UIKIT @_implementationOnly import _SentryPrivate +import PhotosUI import UIKit @available(iOS 13.0, *) @@ -14,6 +17,8 @@ protocol SentryUserFeedbackFormDelegate: NSObjectProtocol { class SentryUserFeedbackForm: UIViewController { let config: SentryUserFeedbackConfiguration weak var delegate: (any SentryUserFeedbackFormDelegate)? + var editingTextField: UITextField? + var editingTextView: UITextView? override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { config.theme.updateDefaultFonts() @@ -28,28 +33,108 @@ class SentryUserFeedbackForm: UIViewController { view.backgroundColor = config.theme.background initLayout() themeElements() + + let nc = NotificationCenter.default + nc.addObserver(self, selector: #selector(showedKeyboard(note:)), name: UIResponder.keyboardDidShowNotification, object: nil) + nc.addObserver(self, selector: #selector(hidKeyboard), name: UIResponder.keyboardWillHideNotification, object: nil) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + // MARK: UI Elements + + func themeElements() { + [fullNameTextField, emailTextField].forEach { + $0.font = config.theme.font + $0.adjustsFontForContentSizeCategory = true + if config.theme.outlineStyle == config.theme.defaultOutlineStyle { + $0.borderStyle = .roundedRect + } else { + $0.layer.cornerRadius = config.theme.outlineStyle.cornerRadius + $0.layer.borderWidth = config.theme.outlineStyle.outlineWidth + $0.layer.borderColor = config.theme.outlineStyle.outlineColor.cgColor + } + } + + [fullNameTextField, emailTextField, messageTextView].forEach { + $0.backgroundColor = config.theme.inputBackground + } + + [fullNameLabel, emailLabel, messageLabel].forEach { + $0.font = config.theme.titleFont + $0.adjustsFontForContentSizeCategory = true + } + + [submitButton, addScreenshotButton, removeScreenshotButton, cancelButton].forEach { + $0.titleLabel?.font = config.theme.titleFont + $0.titleLabel?.adjustsFontForContentSizeCategory = true + } + + [submitButton, addScreenshotButton, removeScreenshotButton, cancelButton, messageTextView].forEach { + $0.layer.cornerRadius = config.theme.outlineStyle.cornerRadius + $0.layer.borderWidth = config.theme.outlineStyle.outlineWidth + $0.layer.borderColor = config.theme.outlineStyle.outlineColor.cgColor + } + + [addScreenshotButton, removeScreenshotButton, cancelButton].forEach { + $0.backgroundColor = config.theme.buttonBackground + $0.setTitleColor(config.theme.buttonForeground, for: .normal) + } + } + // MARK: Actions func addScreenshotButtonTapped() { - + // the iOS photo picker UI doesn't play nicely with XCUITest, so we'll just mock the selection here +#if TEST || TESTCI + //swiftlint:disable force_try force_unwrapping + let url = Bundle.main.url(forResource: "Tongariro", withExtension: "jpg")! + let image = try! UIImage(data: Data(contentsOf: url))! + //swiftlint:ensable force_try force_unwrapping + addedScreenshot(image: image) + return +#else + let imagePickerController = UIImagePickerController() + imagePickerController.delegate = self + imagePickerController.sourceType = .photoLibrary + imagePickerController.allowsEditing = true + present(imagePickerController, animated: config.animations) +#endif // TEST || TESTCI } func removeScreenshotButtonTapped() { - + screenshotImageView.image = nil + removeScreenshotStack.isHidden = true + addScreenshotButton.isHidden = false } - //swiftlint:disable todo func submitFeedbackButtonTapped() { - // TODO: validate and package entries + var missing = [String]() + + if config.formConfig.isNameRequired && !fullNameTextField.hasText { + missing.append("name") + } + + if config.formConfig.isEmailRequired && !emailTextField.hasText { + missing.append("email") + } + + if !messageTextView.hasText { + missing.append("description") + } + + guard missing.isEmpty else { + let list = missing.count == 1 ? missing[0] : missing[0 ..< missing.count - 1].joined(separator: ", ") + " and " + missing[missing.count - 1] + let alert = UIAlertController(title: "Error", message: "You must provide all required information. Please check the following fields: \(list).", preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "OK", style: .default)) + present(alert, animated: config.animations) + return + } + delegate?.confirmed() } - //swiftlint:enable todo func cancelButtonTapped() { delegate?.cancelled() @@ -61,27 +146,38 @@ class SentryUserFeedbackForm: UIViewController { let logoWidth: CGFloat = 47 lazy var messageTextViewHeightConstraint = messageTextView.heightAnchor.constraint(equalToConstant: config.theme.font.lineHeight * 5) lazy var logoViewWidthConstraint = sentryLogoView.widthAnchor.constraint(equalToConstant: logoWidth * config.scaleFactor) - lazy var messagePlaceholderLeadingConstraint = messageTextViewPlaceholder.leadingAnchor.constraint(equalTo: messageTextView.leadingAnchor, constant: messageTextView.textContainerInset.left + 5) - lazy var messagePlaceholderTopConstraint = messageTextViewPlaceholder.topAnchor.constraint(equalTo: messageTextView.topAnchor, constant: messageTextView.textContainerInset.top) lazy var fullNameTextFieldHeightConstraint = fullNameTextField.heightAnchor.constraint(equalToConstant: formElementHeight * config.scaleFactor) lazy var emailTextFieldHeightConstraint = emailTextField.heightAnchor.constraint(equalToConstant: formElementHeight * config.scaleFactor) lazy var addScreenshotButtonHeightConstraint = addScreenshotButton.heightAnchor.constraint(equalToConstant: formElementHeight * config.scaleFactor) lazy var removeScreenshotButtonHeightConstraint = removeScreenshotButton.heightAnchor.constraint(equalToConstant: formElementHeight * config.scaleFactor) lazy var submitButtonHeightConstraint = submitButton.heightAnchor.constraint(equalToConstant: formElementHeight * config.scaleFactor) lazy var cancelButtonHeightConstraint = cancelButton.heightAnchor.constraint(equalToConstant: formElementHeight * config.scaleFactor) + lazy var screenshotImageAspectRatioConstraint = screenshotImageView.widthAnchor.constraint(equalTo: screenshotImageView.heightAnchor) + + // the extra 5 pixels was observed experimentally and is invariant under changes in dynamic type sizes + lazy var messagePlaceholderLeadingConstraint = messageTextViewPlaceholder.leadingAnchor.constraint(equalTo: messageTextView.leadingAnchor, constant: messageTextView.textContainerInset.left + 5) + lazy var messagePlaceholderTrailingConstraint = messageTextViewPlaceholder.trailingAnchor.constraint(equalTo: messageTextView.trailingAnchor, constant: messageTextView.textContainerInset.right - 5) + lazy var messagePlaceholderTopConstraint = messageTextViewPlaceholder.topAnchor.constraint(equalTo: messageTextView.topAnchor, constant: messageTextView.textContainerInset.top) + lazy var messagePlaceholderBottomConstraint = messageTextViewPlaceholder.bottomAnchor.constraint(equalTo: messageTextView.bottomAnchor, constant: messageTextView.textContainerInset.bottom) + + func setScrollViewBottomInset(_ inset: CGFloat) { + scrollView.contentInset = .init(top: config.margin, left: config.margin, bottom: inset + config.margin, right: config.margin) + scrollView.scrollIndicatorInsets = .init(top: 0, left: 0, bottom: inset, right: 0) + } func initLayout() { + setScrollViewBottomInset(0) NSLayoutConstraint.activate([ - scrollView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: config.margin), - scrollView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: config.margin), - scrollView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -config.margin), - scrollView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -config.margin), + scrollView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), + scrollView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + scrollView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), stack.topAnchor.constraint(equalTo: scrollView.topAnchor), stack.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor), stack.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor), stack.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor), - stack.widthAnchor.constraint(equalTo: scrollView.widthAnchor), + stack.widthAnchor.constraint(equalTo: scrollView.widthAnchor, constant: -2 * config.margin), messageTextViewHeightConstraint, @@ -95,13 +191,15 @@ class SentryUserFeedbackForm: UIViewController { submitButtonHeightConstraint, cancelButtonHeightConstraint, - // the extra 5 pixels was observed experimentally and is invariant under changes in dynamic type sizes messagePlaceholderLeadingConstraint, - messagePlaceholderTopConstraint + messagePlaceholderTopConstraint, + messagePlaceholderTrailingConstraint, + + screenshotImageView.heightAnchor.constraint(equalTo: addScreenshotButton.heightAnchor), + screenshotImageAspectRatioConstraint ]) } - /// Update the constants of constraints and any other layout, like transforms, in response to e.g. accessibility dynamic text size changes. func updateLayout() { let verticalPadding: CGFloat = 8 messageTextView.textContainerInset = .init(top: verticalPadding * config.scaleFactor, left: 2 * config.scaleFactor, bottom: verticalPadding * config.scaleFactor, right: 2 * config.scaleFactor) @@ -109,6 +207,7 @@ class SentryUserFeedbackForm: UIViewController { messageTextViewHeightConstraint.constant = config.theme.font.lineHeight * 5 logoViewWidthConstraint.constant = logoWidth * config.scaleFactor messagePlaceholderLeadingConstraint.constant = messageTextView.textContainerInset.left + 5 + messagePlaceholderTrailingConstraint.constant = messageTextView.textContainerInset.right - 5 messagePlaceholderTopConstraint.constant = messageTextView.textContainerInset.top fullNameTextFieldHeightConstraint.constant = formElementHeight * config.scaleFactor emailTextFieldHeightConstraint.constant = formElementHeight * config.scaleFactor @@ -118,47 +217,18 @@ class SentryUserFeedbackForm: UIViewController { cancelButtonHeightConstraint.constant = formElementHeight * config.scaleFactor } - // MARK: UI Elements + func showedKeyboard(note: Notification) { + guard let keyboardValue = note.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue else { return } + let keyboardViewEndFrame = self.view.convert(keyboardValue.cgRectValue, from: self.view.window) + self.setScrollViewBottomInset(keyboardViewEndFrame.height - self.view.safeAreaInsets.bottom) + } - func themeElements() { - [fullNameTextField, emailTextField].forEach { - $0.font = config.theme.font - $0.adjustsFontForContentSizeCategory = true - if config.theme.outlineStyle == config.theme.defaultOutlineStyle { - $0.borderStyle = .roundedRect - } else { - $0.layer.cornerRadius = config.theme.outlineStyle.cornerRadius - $0.layer.borderWidth = config.theme.outlineStyle.outlineWidth - $0.layer.borderColor = config.theme.outlineStyle.outlineColor.cgColor - } - } - - [fullNameTextField, emailTextField, messageTextView].forEach { - $0.backgroundColor = config.theme.inputBackground - } - - [fullNameLabel, emailLabel, messageLabel].forEach { - $0.font = config.theme.titleFont - $0.adjustsFontForContentSizeCategory = true - } - - [submitButton, addScreenshotButton, removeScreenshotButton, cancelButton].forEach { - $0.titleLabel?.font = config.theme.titleFont - $0.titleLabel?.adjustsFontForContentSizeCategory = true - } - - [submitButton, addScreenshotButton, removeScreenshotButton, cancelButton, messageTextView].forEach { - $0.layer.cornerRadius = config.theme.outlineStyle.cornerRadius - $0.layer.borderWidth = config.theme.outlineStyle.outlineWidth - $0.layer.borderColor = config.theme.outlineStyle.outlineColor.cgColor - } - - [addScreenshotButton, removeScreenshotButton, cancelButton].forEach { - $0.backgroundColor = config.theme.buttonBackground - $0.setTitleColor(config.theme.buttonForeground, for: .normal) - } + func hidKeyboard() { + self.setScrollViewBottomInset(0) } + // MARK: UI Elements + lazy var formTitleLabel = { let label = UILabel(frame: .zero) label.text = config.formConfig.formTitle @@ -189,6 +259,8 @@ class SentryUserFeedbackForm: UIViewController { let field = UITextField(frame: .zero) field.placeholder = config.formConfig.namePlaceholder field.accessibilityLabel = config.formConfig.nameTextFieldAccessibilityLabel + field.accessibilityIdentifier = "io.sentry.feedback.form.name" + field.delegate = self return field }() @@ -202,6 +274,9 @@ class SentryUserFeedbackForm: UIViewController { let field = UITextField(frame: .zero) field.placeholder = config.formConfig.emailPlaceholder field.accessibilityLabel = config.formConfig.emailTextFieldAccessibilityLabel + field.accessibilityIdentifier = "io.sentry.feedback.form.email" + field.delegate = self + field.keyboardType = .emailAddress return field }() @@ -215,6 +290,7 @@ class SentryUserFeedbackForm: UIViewController { let label = UILabel(frame: .zero) label.text = config.formConfig.messagePlaceholder label.font = config.theme.font + label.numberOfLines = 0 label.textColor = .placeholderText label.translatesAutoresizingMaskIntoConstraints = false label.adjustsFontForContentSizeCategory = true @@ -227,14 +303,18 @@ class SentryUserFeedbackForm: UIViewController { textView.adjustsFontForContentSizeCategory = true textView.accessibilityLabel = config.formConfig.messageTextViewAccessibilityLabel textView.delegate = self + textView.accessibilityIdentifier = "io.sentry.feedback.form.message" return textView }() + lazy var screenshotImageView = UIImageView() + lazy var addScreenshotButton = { let button = UIButton(frame: .zero) button.setTitle(config.formConfig.addScreenshotButtonLabel, for: .normal) button.accessibilityLabel = config.formConfig.addScreenshotButtonAccessibilityLabel button.addTarget(self, action: #selector(addScreenshotButtonTapped), for: .touchUpInside) + button.accessibilityIdentifier = "io.sentry.feedback.form.add-screenshot" return button }() @@ -243,6 +323,7 @@ class SentryUserFeedbackForm: UIViewController { button.setTitle(config.formConfig.removeScreenshotButtonLabel, for: .normal) button.accessibilityLabel = config.formConfig.removeScreenshotButtonAccessibilityLabel button.addTarget(self, action: #selector(removeScreenshotButtonTapped), for: .touchUpInside) + button.accessibilityIdentifier = "io.sentry.feedback.form.remove-screenshot" return button }() @@ -264,6 +345,12 @@ class SentryUserFeedbackForm: UIViewController { return button }() + lazy var removeScreenshotStack = { + let stack = UIStackView(arrangedSubviews: [self.screenshotImageView, self.removeScreenshotButton]) + stack.spacing = config.theme.font.lineHeight - config.theme.font.xHeight + return stack + }() + lazy var stack = { let headerStack = UIStackView(arrangedSubviews: [self.formTitleLabel]) if self.config.formConfig.showBranding { @@ -297,6 +384,8 @@ class SentryUserFeedbackForm: UIViewController { if self.config.formConfig.enableScreenshot { messageAndScreenshotStack.addArrangedSubview(self.addScreenshotButton) + messageAndScreenshotStack.addArrangedSubview(removeScreenshotStack) + self.removeScreenshotStack.isHidden = true } messageAndScreenshotStack.spacing = config.theme.font.lineHeight - config.theme.font.xHeight @@ -322,16 +411,52 @@ class SentryUserFeedbackForm: UIViewController { scrollView.addSubview(stack) scrollView.translatesAutoresizingMaskIntoConstraints = false scrollView.addSubview(messageTextViewPlaceholder) + scrollView.keyboardDismissMode = .interactive return scrollView }() } +// MARK: UITextFieldDelegate +@available(iOS 13.0, *) +extension SentryUserFeedbackForm: UITextFieldDelegate { + func textFieldDidBeginEditing(_ textField: UITextField) { + editingTextField = textField + editingTextView = nil + } +} + // MARK: UITextViewDelegate @available(iOS 13.0, *) extension SentryUserFeedbackForm: UITextViewDelegate { func textViewDidChange(_ textView: UITextView) { + editingTextField = nil + editingTextView = textView messageTextViewPlaceholder.isHidden = textView.text != "" } } +// MARK: UIImagePickerControllerDelegate & UINavigationControllerDelegate +@available(iOS 13.0, *) +extension SentryUserFeedbackForm: UIImagePickerControllerDelegate & UINavigationControllerDelegate { + func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) { + guard let photo = info[.editedImage] as? UIImage else { + // TODO: handle error + return + } + addedScreenshot(image: photo) + dismiss(animated: config.animations) + } + + func addedScreenshot(image: UIImage) { + screenshotImageView.image = image + screenshotImageAspectRatioConstraint.isActive = false + screenshotImageAspectRatioConstraint = screenshotImageView.widthAnchor.constraint(equalTo: screenshotImageView.heightAnchor, multiplier: image.size.width / image.size.height) + screenshotImageAspectRatioConstraint.isActive = true + addScreenshotButton.isHidden = true + removeScreenshotStack.isHidden = false + } +} + #endif // os(iOS) && !SENTRY_NO_UIKIT + +//swiftlint:enable todo type_body_length file_length diff --git a/Sources/Swift/Integrations/UserFeedback/SentryUserFeedbackWidget.swift b/Sources/Swift/Integrations/UserFeedback/SentryUserFeedbackWidget.swift index 0ccaaf38157..0233bd50aa1 100644 --- a/Sources/Swift/Integrations/UserFeedback/SentryUserFeedbackWidget.swift +++ b/Sources/Swift/Integrations/UserFeedback/SentryUserFeedbackWidget.swift @@ -1,3 +1,5 @@ +//swiftlint:disable todo + import Foundation #if os(iOS) && !SENTRY_NO_UIKIT @_implementationOnly import _SentryPrivate @@ -15,7 +17,7 @@ struct SentryUserFeedbackWidget { self.setWidget(visible: false) let form = SentryUserFeedbackForm(config: self.config, delegate: self) form.presentationController?.delegate = self - self.present(form, animated: self.config.widgetConfig.animations) + self.present(form, animated: self.config.animations) }) let config: SentryUserFeedbackConfiguration @@ -48,7 +50,7 @@ struct SentryUserFeedbackWidget { // MARK: Helpers func setWidget(visible: Bool) { - if config.widgetConfig.animations { + if config.animations { UIView.animate(withDuration: 0.3, delay: 0, options: .curveEaseInOut) { self.button.alpha = visible ? 1 : 0 } @@ -61,7 +63,7 @@ struct SentryUserFeedbackWidget { func closeForm() { setWidget(visible: true) - dismiss(animated: config.widgetConfig.animations) + dismiss(animated: config.animations) } // MARK: SentryUserFeedbackFormDelegate @@ -111,3 +113,5 @@ struct SentryUserFeedbackWidget { } #endif // os(iOS) && !SENTRY_NO_UIKIT + +//swiftlint:enable todo diff --git a/Sources/Swift/Integrations/UserFeedback/SentryUserFeedbackWidgetButtonView.swift b/Sources/Swift/Integrations/UserFeedback/SentryUserFeedbackWidgetButtonView.swift index 42d292a748f..60875498c22 100644 --- a/Sources/Swift/Integrations/UserFeedback/SentryUserFeedbackWidgetButtonView.swift +++ b/Sources/Swift/Integrations/UserFeedback/SentryUserFeedbackWidgetButtonView.swift @@ -26,6 +26,7 @@ class SentryUserFeedbackWidgetButtonView: UIView { super.init(frame: .zero) translatesAutoresizingMaskIntoConstraints = false accessibilityLabel = config.widgetConfig.widgetAccessibilityLabel ?? config.widgetConfig.labelText + accessibilityIdentifier = "io.sentry.feedback.widget" let atLeastOneElement = config.widgetConfig.showIcon || config.widgetConfig.labelText != nil let preconditionMessage = "User Feedback widget attempted to be displayed with neither text label or icon."