diff --git a/Flinky.xcodeproj/project.pbxproj b/Flinky.xcodeproj/project.pbxproj index eed6623..81f3ceb 100644 --- a/Flinky.xcodeproj/project.pbxproj +++ b/Flinky.xcodeproj/project.pbxproj @@ -1859,8 +1859,8 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/getsentry/sentry-cocoa"; requirement = { - kind = exactVersion; - version = 9.15.0; + branch = "feat/feedback-presentation-api"; + kind = branch; }; traits = ( ); diff --git a/Flinky.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Flinky.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index e358642..2d7354e 100644 --- a/Flinky.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Flinky.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/getsentry/sentry-cocoa", "state" : { - "revision" : "cef29e94feb00b1b712514443d6d70b09ef20355", - "version" : "9.15.0" + "branch" : "feat/feedback-presentation-api", + "revision" : "3fa7efdac738a631246f4c0ccf7f8217762ba9ef" } }, { diff --git a/Targets/App/Sources/Environment/Environment+Feedback.swift b/Targets/App/Sources/Environment/Environment+Feedback.swift new file mode 100644 index 0000000..4e98523 --- /dev/null +++ b/Targets/App/Sources/Environment/Environment+Feedback.swift @@ -0,0 +1,24 @@ +import SwiftUI + +struct FeedbackAction { + private let handler: () -> Void + + init(handler: @escaping () -> Void = {}) { + self.handler = handler + } + + func show() { + handler() + } +} + +extension EnvironmentValues { + var feedback: FeedbackAction { + get { self[FeedbackKey.self] } + set { self[FeedbackKey.self] = newValue } + } + + private struct FeedbackKey: EnvironmentKey { + static let defaultValue = FeedbackAction() + } +} diff --git a/Targets/App/Sources/Generated/L10n.swift b/Targets/App/Sources/Generated/L10n.swift index 63132c4..b752d78 100644 --- a/Targets/App/Sources/Generated/L10n.swift +++ b/Targets/App/Sources/Generated/L10n.swift @@ -39,6 +39,16 @@ internal enum L10n { /// New List internal static let title = L10n.tr("create-list.title", fallback: "New List") } + internal enum Feedback { + internal enum Toast { + internal enum Submit { + /// Failed to submit feedback + internal static let error = L10n.tr("feedback.toast.submit.error", fallback: "Failed to submit feedback") + /// Feedback submitted successfully + internal static let success = L10n.tr("feedback.toast.submit.success", fallback: "Feedback submitted successfully") + } + } + } internal enum LinkDetail { internal enum EditLink { /// Edit @@ -217,6 +227,16 @@ internal enum L10n { internal static let label = L10n.tr("shared.button.edit.accessibility.label", fallback: "Edit") } } + internal enum Feedback { + /// Send Feedback + internal static let label = L10n.tr("shared.button.feedback.label", fallback: "Send Feedback") + internal enum Accessibility { + /// Send feedback to help improve the app + internal static let hint = L10n.tr("shared.button.feedback.accessibility.hint", fallback: "Send feedback to help improve the app") + /// Send Feedback + internal static let label = L10n.tr("shared.button.feedback.accessibility.label", fallback: "Send Feedback") + } + } internal enum NewLink { internal enum Accessibility { /// Create a new link in this list diff --git a/Targets/App/Sources/Main/FlinkyApp.swift b/Targets/App/Sources/Main/FlinkyApp.swift index 04966cf..88994e9 100644 --- a/Targets/App/Sources/Main/FlinkyApp.swift +++ b/Targets/App/Sources/Main/FlinkyApp.swift @@ -13,15 +13,10 @@ struct FlinkyApp: App { private let sharedModelContainer: ModelContainer init() { - SentrySDK.start { options in - Self.configureSentry(options: options) + SentrySDK.start { [_toastManager] options in + Self.configureSentry(options: options, toastManager: { _toastManager.wrappedValue }) } - // Start app health observation for system-level metrics - // (thermal state, network reachability, app state transitions) - // Reference: https://github.com/getsentry/sentry-cocoa/issues/7000 - AppHealthObserver.shared.startObserving() - do { sharedModelContainer = try SharedModelContainerFactory.make( isStoredInMemoryOnly: ProcessInfo.processInfo.isTestingEnabled @@ -30,6 +25,11 @@ struct FlinkyApp: App { fatalError("Failed to create shared ModelContainer: \(error)") } + // Start app health observation for system-level metrics + // (thermal state, network reachability, app state transitions) + // Reference: https://github.com/getsentry/sentry-cocoa/issues/7000 + AppHealthObserver.shared.startObserving() + // Seed if needed on first app launch DataSeedingService.seedDataIfNeeded(modelContext: sharedModelContainer.mainContext) } @@ -39,7 +39,11 @@ struct FlinkyApp: App { /// This method is defined as `private static` to because it is called from a non-mutating context. /// /// - Parameter options: Options structure to configure Sentry. - private static func configureSentry(options: Options) { // swiftlint:disable:this function_body_length + /// - Parameter toastManager: Closure providing lazy access to the toast manager for feedback callbacks. + private static func configureSentry( // swiftlint:disable:this function_body_length + options: Options, + toastManager: @escaping () -> ToastManager + ) { // Disable Sentry for tests because it produces a lot of noise. if ProcessInfo.processInfo.isTestingEnabled { Self.logger.warning("Sentry is disabled in test environment") @@ -117,15 +121,6 @@ struct FlinkyApp: App { // Configure User Feedback options.configureUserFeedback = { feedbackOptions in feedbackOptions.animations = true - feedbackOptions.configureWidget = { widgetOptions in - widgetOptions.autoInject = false // Disable automatic injection of the widget, because it's not supported in SwiftUI. - widgetOptions.labelText = "Send Feedback" - widgetOptions.showIcon = true - widgetOptions.widgetAccessibilityLabel = "Feedback Widget" - widgetOptions.windowLevel = UIWindow.Level.normal + 1 - widgetOptions.location = [.bottom, .trailing] - widgetOptions.layoutUIOffset = .init(horizontal: 18, vertical: 80) - } feedbackOptions.useShakeGesture = true feedbackOptions.showFormForScreenshots = true feedbackOptions.configureForm = { formOptions in @@ -203,6 +198,24 @@ struct FlinkyApp: App { // Track feedback form closing using metrics - better for aggregate counts than individual events SentryMetricsHelper.trackFeedbackFormClosed() } + feedbackOptions.onSubmitSuccess = { _ in + let breadcrumb = Breadcrumb(level: .info, category: "user_feedback") + breadcrumb.message = "User successfully submitted feedback" + SentrySDK.addBreadcrumb(breadcrumb) + + DispatchQueue.main.async { + toastManager().success(description: L10n.Feedback.Toast.Submit.success) + } + } + feedbackOptions.onSubmitError = { error in + let breadcrumb = Breadcrumb(level: .error, category: "user_feedback") + breadcrumb.message = "Feedback submission failed: \(error.localizedDescription)" + SentrySDK.addBreadcrumb(breadcrumb) + + DispatchQueue.main.async { + toastManager().error(description: L10n.Feedback.Toast.Submit.error) + } + } } // Configure Logs @@ -222,6 +235,9 @@ struct FlinkyApp: App { var body: some Scene { WindowGroup { MainContainerView() + .environment(\.feedback, FeedbackAction { + SentrySDK.feedback.show() + }) .toaster(toastManager) .configureOnLaunch { options in options.publicKey = "30d2f7cc2fa469eaf8e4bdf958ad9d66bce491a7da1fb08ff0a7156a8e15a47d" diff --git a/Targets/App/Sources/Resources/Localizable.xcstrings b/Targets/App/Sources/Resources/Localizable.xcstrings index c89ff60..9961efe 100644 --- a/Targets/App/Sources/Resources/Localizable.xcstrings +++ b/Targets/App/Sources/Resources/Localizable.xcstrings @@ -795,6 +795,42 @@ } } }, + "shared.button.feedback.accessibility.hint": { + "comment": "Feedback button accessibility hint", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Send feedback to help improve the app" + } + } + } + }, + "shared.button.feedback.accessibility.label": { + "comment": "Feedback button accessibility label", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Send Feedback" + } + } + } + }, + "shared.button.feedback.label": { + "comment": "Feedback button text", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Send Feedback" + } + } + } + }, "shared.color-picker.accessibility.label": { "comment": "Color picker accessibility label", "extractionState": "manual", @@ -1466,6 +1502,30 @@ } } } + }, + "feedback.toast.submit.success": { + "comment": "Toast message shown when feedback is submitted successfully", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Feedback submitted successfully" + } + } + } + }, + "feedback.toast.submit.error": { + "comment": "Toast message shown when feedback submission fails", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Failed to submit feedback" + } + } + } } }, "version": "1.0" diff --git a/Targets/App/Sources/Resources/Settings.bundle/Licenses.latest_result.txt b/Targets/App/Sources/Resources/Settings.bundle/Licenses.latest_result.txt index 8032d35..a7887ec 100644 --- a/Targets/App/Sources/Resources/Settings.bundle/Licenses.latest_result.txt +++ b/Targets/App/Sources/Resources/Settings.bundle/Licenses.latest_result.txt @@ -1,12 +1,12 @@ name: OnLaunch-iOS-Client, nameSpecified: OnLaunch-iOS-Client, owner: kula-app, version: 0.0.6, source: https://github.com/kula-app/OnLaunch-iOS-Client -name: sentry-cocoa, nameSpecified: Sentry, owner: getsentry, version: 9.15.0, source: https://github.com/getsentry/sentry-cocoa +name: sentry-cocoa, nameSpecified: sentry-cocoa, owner: getsentry, version: , source: https://github.com/getsentry/sentry-cocoa name: SFSafeSymbols, nameSpecified: SFSafeSymbols, owner: SFSafeSymbols, version: 7.0.0, source: https://github.com/SFSafeSymbols/SFSafeSymbols name: OnLaunch-iOS-Client, nameSpecified: OnLaunch-iOS-Client, owner: kula-app, version: 0.0.6, source: https://github.com/kula-app/OnLaunch-iOS-Client -name: sentry-cocoa, nameSpecified: Sentry, owner: getsentry, version: 9.15.0, source: https://github.com/getsentry/sentry-cocoa +name: sentry-cocoa, nameSpecified: sentry-cocoa, owner: getsentry, version: , source: https://github.com/getsentry/sentry-cocoa name: SFSafeSymbols, nameSpecified: SFSafeSymbols, owner: SFSafeSymbols, version: 7.0.0, source: https://github.com/SFSafeSymbols/SFSafeSymbols diff --git a/Targets/App/Sources/Resources/Settings.bundle/Licenses.plist b/Targets/App/Sources/Resources/Settings.bundle/Licenses.plist index 1829a0e..ffdd7ff 100644 --- a/Targets/App/Sources/Resources/Settings.bundle/Licenses.plist +++ b/Targets/App/Sources/Resources/Settings.bundle/Licenses.plist @@ -22,7 +22,7 @@ File Licenses/sentry-cocoa Title - Sentry (9.15.0) + sentry-cocoa Type PSChildPaneSpecifier diff --git a/Targets/App/Sources/UI/CreateLinkEditor/CreateLinkEditorRenderView.swift b/Targets/App/Sources/UI/CreateLinkEditor/CreateLinkEditorRenderView.swift index bd8ccba..44c055e 100644 --- a/Targets/App/Sources/UI/CreateLinkEditor/CreateLinkEditorRenderView.swift +++ b/Targets/App/Sources/UI/CreateLinkEditor/CreateLinkEditorRenderView.swift @@ -1,3 +1,4 @@ +import SFSafeSymbols import SwiftUI struct CreateLinkEditorRenderView: View { @@ -7,6 +8,7 @@ struct CreateLinkEditorRenderView: View { } @Environment(\.dismiss) private var dismiss + @Environment(\.feedback) private var feedback @Binding var name: String @Binding var url: String @@ -55,6 +57,16 @@ struct CreateLinkEditorRenderView: View { .accessibilityHint(L10n.Shared.Button.Cancel.Accessibility.hint) .accessibilityIdentifier("create-link.cancel.button") } + ToolbarItem(placement: .topBarTrailing) { + Button(action: { + feedback.show() + }, label: { + Label(L10n.Shared.Button.Feedback.label, systemSymbol: .megaphone) + }) + .accessibilityLabel(L10n.Shared.Button.Feedback.Accessibility.label) + .accessibilityHint(L10n.Shared.Button.Feedback.Accessibility.hint) + .accessibilityIdentifier("create-link.feedback.button") + } ToolbarItem(placement: .confirmationAction) { if #available(iOS 26, *) { Button(role: .confirm) { diff --git a/Targets/App/Sources/UI/CreateListEditor/CreateLinkListEditorRenderView.swift b/Targets/App/Sources/UI/CreateListEditor/CreateLinkListEditorRenderView.swift index 20b0eff..82e46bc 100644 --- a/Targets/App/Sources/UI/CreateListEditor/CreateLinkListEditorRenderView.swift +++ b/Targets/App/Sources/UI/CreateListEditor/CreateLinkListEditorRenderView.swift @@ -1,3 +1,4 @@ +import SFSafeSymbols import SwiftUI struct CreateLinkListEditorRenderView: View { @@ -6,6 +7,7 @@ struct CreateLinkListEditorRenderView: View { } @Environment(\.dismiss) private var dismiss + @Environment(\.feedback) private var feedback @Binding var name: String @FocusState private var focusedField: CreateField? @@ -27,6 +29,16 @@ struct CreateLinkListEditorRenderView: View { .navigationTitle(L10n.CreateList.title) .accessibilityIdentifier("create-link-list.container") .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button(action: { + feedback.show() + }, label: { + Label(L10n.Shared.Button.Feedback.label, systemSymbol: .megaphone) + }) + .accessibilityLabel(L10n.Shared.Button.Feedback.Accessibility.label) + .accessibilityHint(L10n.Shared.Button.Feedback.Accessibility.hint) + .accessibilityIdentifier("create-link-list.feedback.button") + } ToolbarItem(placement: .cancellationAction) { Button(role: .cancel) { dismiss() diff --git a/Targets/App/Sources/UI/LinkDetail/LinkDetailRenderView.swift b/Targets/App/Sources/UI/LinkDetail/LinkDetailRenderView.swift index cedb8b5..d6d63b6 100644 --- a/Targets/App/Sources/UI/LinkDetail/LinkDetailRenderView.swift +++ b/Targets/App/Sources/UI/LinkDetail/LinkDetailRenderView.swift @@ -7,6 +7,7 @@ import SwiftUI struct LinkDetailRenderView: View { @Environment(\.dismiss) private var dismiss + @Environment(\.feedback) private var feedback let linkId: UUID @@ -30,6 +31,16 @@ struct LinkDetailRenderView: View { } .background(Color(UIColor.systemGroupedBackground)) .toolbar { + ToolbarItemGroup(placement: .topBarLeading) { + Button(action: { + feedback.show() + }, label: { + Label(L10n.Shared.Button.Feedback.label, systemSymbol: .megaphone) + }) + .accessibilityLabel(L10n.Shared.Button.Feedback.Accessibility.label) + .accessibilityHint(L10n.Shared.Button.Feedback.Accessibility.hint) + .accessibilityIdentifier("link-detail.feedback.button") + } ToolbarItemGroup(placement: .topBarLeading) { Menu { Button { diff --git a/Targets/App/Sources/UI/LinkInfo/LinkInfoRenderView.swift b/Targets/App/Sources/UI/LinkInfo/LinkInfoRenderView.swift index 75faa90..4e1475a 100644 --- a/Targets/App/Sources/UI/LinkInfo/LinkInfoRenderView.swift +++ b/Targets/App/Sources/UI/LinkInfo/LinkInfoRenderView.swift @@ -3,6 +3,8 @@ import SFSafeSymbols import SwiftUI struct LinkInfoRenderView: View { + @Environment(\.feedback) private var feedback + @Binding var name: String @Binding var url: String @Binding var color: ListColor @@ -34,6 +36,16 @@ struct LinkInfoRenderView: View { .accessibilityHint(L10n.Shared.Button.Cancel.Accessibility.hint) .accessibilityIdentifier("link-info.cancel.button") } + ToolbarItem(placement: .topBarTrailing) { + Button(action: { + feedback.show() + }, label: { + Label(L10n.Shared.Button.Feedback.label, systemSymbol: .megaphone) + }) + .accessibilityLabel(L10n.Shared.Button.Feedback.Accessibility.label) + .accessibilityHint(L10n.Shared.Button.Feedback.Accessibility.hint) + .accessibilityIdentifier("link-info.feedback.button") + } if #available(iOS 26, *) { ToolbarItem(placement: .confirmationAction) { Button(role: .confirm) { @@ -171,8 +183,8 @@ extension LinkInfoRenderView { .font(.system(size: 22, weight: .medium)) .foregroundStyle( symbol.isEmoji - ? Color.blue - : (colorScheme == .light ? Color.gray.mix(with: Color.black, by: 0.3) : Color.gray) + ? Color.blue + : (colorScheme == .light ? Color.gray.mix(with: Color.black, by: 0.3) : Color.gray) ) .frame(maxWidth: .infinity, maxHeight: .infinity) .padding(6) @@ -180,7 +192,7 @@ extension LinkInfoRenderView { .frame(maxWidth: .infinity, maxHeight: .infinity) .background( symbol.isEmoji - ? Color.blue.opacity(0.15) : Color.gray.opacity(colorScheme == .light ? 0.1 : 0.2) + ? Color.blue.opacity(0.15) : Color.gray.opacity(colorScheme == .light ? 0.1 : 0.2) ) .clipShape(Circle()) .contentShape(Circle()) diff --git a/Targets/App/Sources/UI/LinkListDetail/LinkListDetailRenderView.swift b/Targets/App/Sources/UI/LinkListDetail/LinkListDetailRenderView.swift index 3c8bcb5..16d2ffb 100644 --- a/Targets/App/Sources/UI/LinkListDetail/LinkListDetailRenderView.swift +++ b/Targets/App/Sources/UI/LinkListDetail/LinkListDetailRenderView.swift @@ -3,6 +3,8 @@ import SFSafeSymbols import SwiftUI struct LinkListDetailRenderView: View { + @Environment(\.feedback) private var feedback + let list: LinkListsDisplayItem let links: [LinkListDetailDisplayItem] @@ -30,6 +32,16 @@ struct LinkListDetailRenderView: View { emptyStateView } .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button(action: { + feedback.show() + }, label: { + Label(L10n.Shared.Button.Feedback.label, systemSymbol: .megaphone) + }) + .accessibilityLabel(L10n.Shared.Button.Feedback.Accessibility.label) + .accessibilityHint(L10n.Shared.Button.Feedback.Accessibility.hint) + .accessibilityIdentifier("link-list-detail.feedback.button") + } ToolbarItem(placement: .navigationBarTrailing) { sortMenu } diff --git a/Targets/App/Sources/UI/LinkLists/LinkListsContainerView.swift b/Targets/App/Sources/UI/LinkLists/LinkListsContainerView.swift index aa6e67f..77aeb00 100644 --- a/Targets/App/Sources/UI/LinkLists/LinkListsContainerView.swift +++ b/Targets/App/Sources/UI/LinkLists/LinkListsContainerView.swift @@ -194,11 +194,6 @@ struct LinkListsContainerView: View { } } .sentryTrace("LINK_LISTS_VIEW") - .onAppear { - // Auto-injecting Sentry feedback widget is currently not supported in SwiftUI. - // Therefore we manually trigger it when the view appears. - SentrySDK.feedback.showWidget() - } .onChange(of: searchText) { oldValue, newValue in // Track search when user starts searching (transitions from empty to non-empty) if oldValue.isEmpty && !newValue.isEmpty { diff --git a/Targets/App/Sources/UI/LinkLists/LinkListsRenderView.swift b/Targets/App/Sources/UI/LinkLists/LinkListsRenderView.swift index fed4c47..b349365 100644 --- a/Targets/App/Sources/UI/LinkLists/LinkListsRenderView.swift +++ b/Targets/App/Sources/UI/LinkLists/LinkListsRenderView.swift @@ -2,6 +2,8 @@ import SFSafeSymbols import SwiftUI struct LinkListsRenderView: View { + @Environment(\.feedback) private var feedback + let pinnedLists: [LinkListsDisplayItem] let unpinnedLists: [LinkListsDisplayItem] @@ -71,6 +73,16 @@ struct LinkListsRenderView: View { } .ifAvailable(.iOS26, modify: { view in view.toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button(action: { + feedback.show() + }, label: { + Label(L10n.Shared.Button.Feedback.label, systemSymbol: .megaphone) + }) + .accessibilityLabel(L10n.Shared.Button.Feedback.Accessibility.label) + .accessibilityHint(L10n.Shared.Button.Feedback.Accessibility.hint) + .accessibilityIdentifier("link-lists.feedback.button") + } if !pinnedLists.isEmpty || !unpinnedLists.isEmpty { ToolbarItem(placement: .navigationBarTrailing) { EditButton() @@ -111,6 +123,16 @@ struct LinkListsRenderView: View { } }, elseModify: { view in view.toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button(action: { + feedback.show() + }, label: { + Label(L10n.Shared.Button.Feedback.label, systemSymbol: .megaphone) + }) + .accessibilityLabel(L10n.Shared.Button.Feedback.Accessibility.label) + .accessibilityHint(L10n.Shared.Button.Feedback.Accessibility.hint) + .accessibilityIdentifier("link-lists.feedback.button") + } if !pinnedLists.isEmpty || !unpinnedLists.isEmpty { ToolbarItem(placement: .topBarTrailing) { EditButton() diff --git a/Targets/UITests/Sources/UITests.swift b/Targets/UITests/Sources/UITests.swift index dcca258..fb3a12b 100644 --- a/Targets/UITests/Sources/UITests.swift +++ b/Targets/UITests/Sources/UITests.swift @@ -2,15 +2,143 @@ import XCTest final class UITests: XCTestCase { + private var app: XCUIApplication! + override func setUpWithError() throws { - // In UI tests it is usually best to stop immediately when a failure occurs. continueAfterFailure = false + app = XCUIApplication() + app.launchEnvironment["TESTING"] = "1" + app.launch() } @MainActor func testLaunchPerformance() throws { measure(metrics: [XCTApplicationLaunchMetric()]) { - XCUIApplication().launch() + let app = XCUIApplication() + app.launchEnvironment["TESTING"] = "1" + app.launch() } } + + // MARK: - Feedback Button Tests + + func testFeedbackButtonExistsOnListsView() throws { + // -- Act -- + let feedbackButton = app.buttons["link-lists.feedback.button"] + XCTAssertTrue(feedbackButton.waitForExistence(timeout: 5)) + XCTAssertTrue(feedbackButton.isHittable) + feedbackButton.tap() + + // -- Assert -- + assertFeedbackSubmitButtonExists() + } + + func testFeedbackButtonExistsOnListDetailView() throws { + // -- Arrange -- + navigateToListDetail() + + // -- Act -- + let feedbackButton = app.buttons["link-list-detail.feedback.button"] + XCTAssertTrue(feedbackButton.waitForExistence(timeout: 5)) + XCTAssertTrue(feedbackButton.isHittable) + feedbackButton.tap() + + // -- Assert -- + assertFeedbackSubmitButtonExists() + } + + func testFeedbackButtonExistsOnLinkDetailView() throws { + // -- Arrange -- + navigateToLinkDetail() + + // -- Act -- + let feedbackButton = app.buttons["link-detail.feedback.button"] + XCTAssertTrue(feedbackButton.waitForExistence(timeout: 5)) + XCTAssertTrue(feedbackButton.isHittable) + feedbackButton.tap() + + // -- Assert -- + assertFeedbackSubmitButtonExists() + } + + func testFeedbackButtonExistsOnLinkInfoView() throws { + // -- Arrange -- + navigateToLinkDetail() + + let moreMenu = app.buttons["link-detail.more-menu.button"] + XCTAssertTrue(moreMenu.waitForExistence(timeout: 5)) + moreMenu.tap() + + let editButton = app.buttons["Edit"] + XCTAssertTrue(editButton.waitForExistence(timeout: 5)) + editButton.tap() + + // -- Act -- + let feedbackButton = app.buttons["link-info.feedback.button"] + XCTAssertTrue(feedbackButton.waitForExistence(timeout: 5)) + XCTAssertTrue(feedbackButton.isHittable) + feedbackButton.tap() + + // -- Assert -- + assertFeedbackSubmitButtonExists() + } + + func testFeedbackButtonExistsOnCreateListView() throws { + // -- Arrange -- + let createListButton = app.buttons["link-lists.create-list.button"] + XCTAssertTrue(createListButton.waitForExistence(timeout: 5)) + createListButton.tap() + + // -- Act -- + let feedbackButton = app.buttons["create-link-list.feedback.button"] + XCTAssertTrue(feedbackButton.waitForExistence(timeout: 5)) + XCTAssertTrue(feedbackButton.isHittable) + feedbackButton.tap() + + // -- Assert -- + assertFeedbackSubmitButtonExists() + } + + func testFeedbackButtonExistsOnCreateLinkView() throws { + // -- Arrange -- + navigateToListDetail() + + let newLinkButton = app.buttons["link-list-detail.new-link.button"] + XCTAssertTrue(newLinkButton.waitForExistence(timeout: 5)) + newLinkButton.tap() + + // -- Act -- + let feedbackButton = app.buttons["create-link.feedback.button"] + XCTAssertTrue(feedbackButton.waitForExistence(timeout: 5)) + XCTAssertTrue(feedbackButton.isHittable) + feedbackButton.tap() + + // -- Assert -- + assertFeedbackSubmitButtonExists() + } + + // MARK: - Navigation Helpers + + private func navigateToListDetail() { + let myLinksRow = app.cells.containing(.staticText, identifier: "My Links").firstMatch + XCTAssertTrue(myLinksRow.waitForExistence(timeout: 5)) + myLinksRow.tap() + + XCTAssertTrue(app.navigationBars["My Links"].waitForExistence(timeout: 5)) + } + + private func navigateToLinkDetail() { + navigateToListDetail() + + let appleRow = app.cells.containing(.staticText, identifier: "Apple").firstMatch + XCTAssertTrue(appleRow.waitForExistence(timeout: 5)) + appleRow.tap() + } + + // MARK: - Assertion Helpers + + private func assertFeedbackSubmitButtonExists() { + let submitButton = app.buttons["io.sentry.feedback.form.submit"].firstMatch + XCTAssertTrue(submitButton.waitForExistence(timeout: 5)) + } }