diff --git a/CHANGELOG.md b/CHANGELOG.md index a490c59f1..b324d7fdd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup ## 4.12.10 +### Enhancements + +- Adds native haptic feedback support for paywall buttons. Haptic types can be configured in the paywall editor and include light, medium, heavy, success, warning, error, and selection. + ### Fixes - Fixes issue where the `app_install` event was being cleared upon reset, which meant that this couldn't be used with `device.daysSince_app_install` after reset. diff --git a/Sources/SuperwallKit/Paywall/View Controller/Web View/Message Handling/PaywallMessage.swift b/Sources/SuperwallKit/Paywall/View Controller/Web View/Message Handling/PaywallMessage.swift index 63d1777e8..59e952e34 100644 --- a/Sources/SuperwallKit/Paywall/View Controller/Web View/Message Handling/PaywallMessage.swift +++ b/Sources/SuperwallKit/Paywall/View Controller/Web View/Message Handling/PaywallMessage.swift @@ -58,6 +58,7 @@ enum PaywallMessage: Decodable, Equatable { case initiateWebCheckout(contextId: String) case requestStoreReview(ReviewType) case requestPermission(permissionType: PermissionType, requestId: String) + case hapticFeedback(hapticType: String) // All cases below here are sent from device to paywall case paywallClose @@ -98,6 +99,7 @@ enum PaywallMessage: Decodable, Equatable { case requestStoreReview = "request_store_review" case scheduleNotification = "schedule_notification" case requestPermission = "request_permission" + case hapticFeedback = "haptic_feedback" } // Everyone write to eventName, other may use the remaining keys @@ -123,6 +125,7 @@ enum PaywallMessage: Decodable, Equatable { case delay case permissionType case requestId + case hapticType } enum PaywallMessageError: Error { @@ -221,6 +224,11 @@ enum PaywallMessage: Decodable, Equatable { self = .requestPermission(permissionType: permissionType, requestId: requestId) return } + case .hapticFeedback: + if let hapticType = try? values.decode(String.self, forKey: .hapticType) { + self = .hapticFeedback(hapticType: hapticType) + return + } } } diff --git a/Sources/SuperwallKit/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandler.swift b/Sources/SuperwallKit/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandler.swift index 88f81e1d4..b02f9b813 100644 --- a/Sources/SuperwallKit/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandler.swift +++ b/Sources/SuperwallKit/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandler.swift @@ -201,6 +201,8 @@ final class PaywallMessageHandler: WebEventDelegate { requestId: requestId, paywall: paywall ) + case let .hapticFeedback(hapticType): + triggerHapticFeedback(hapticType) } } @@ -508,6 +510,44 @@ final class PaywallMessageHandler: WebEventDelegate { #endif } + /// Triggers haptic feedback based on the type specified from the paywall editor. + private func triggerHapticFeedback(_ hapticType: String) { + #if !os(visionOS) + switch hapticType { + case "light": + let generator = UIImpactFeedbackGenerator(style: .light) + generator.prepare() + generator.impactOccurred() + case "medium": + let generator = UIImpactFeedbackGenerator(style: .medium) + generator.prepare() + generator.impactOccurred() + case "heavy": + let generator = UIImpactFeedbackGenerator(style: .heavy) + generator.prepare() + generator.impactOccurred() + case "success": + let generator = UINotificationFeedbackGenerator() + generator.prepare() + generator.notificationOccurred(.success) + case "warning": + let generator = UINotificationFeedbackGenerator() + generator.prepare() + generator.notificationOccurred(.warning) + case "error": + let generator = UINotificationFeedbackGenerator() + generator.prepare() + generator.notificationOccurred(.error) + case "selection": + let generator = UISelectionFeedbackGenerator() + generator.prepare() + generator.selectionChanged() + default: + break + } + #endif + } + // MARK: - Permission Handling private func handleRequestPermission( diff --git a/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandlerTests.swift b/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandlerTests.swift index e72853f3d..db8cdb258 100644 --- a/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandlerTests.swift +++ b/Tests/SuperwallKitTests/Paywall/View Controller/Web View/Message Handling/PaywallMessageHandlerTests.swift @@ -468,4 +468,81 @@ struct PaywallMessageHandlerTests { shouldDismiss: true )) } + + // MARK: - Haptic Feedback Message Decoding Tests + + @Test + func decodeHapticFeedback_medium() throws { + let json = """ + { + "version": 1, + "payload": { + "events": [ + { + "event_name": "haptic_feedback", + "haptic_type": "medium" + } + ] + } + } + """ + let data = json.data(using: .utf8)! + let wrapped = try JSONDecoder.fromSnakeCase.decode(WrappedPaywallMessages.self, from: data) + let message = wrapped.payload.messages.first + + #expect(message == .hapticFeedback(hapticType: "medium")) + } + + @Test + func decodeHapticFeedback_allTypes() throws { + let hapticTypes = ["light", "medium", "heavy", "success", "warning", "error", "selection"] + + for hapticType in hapticTypes { + let json = """ + { + "version": 1, + "payload": { + "events": [ + { + "event_name": "haptic_feedback", + "haptic_type": "\(hapticType)" + } + ] + } + } + """ + let data = json.data(using: .utf8)! + let wrapped = try JSONDecoder.fromSnakeCase.decode(WrappedPaywallMessages.self, from: data) + let message = wrapped.payload.messages.first + + #expect(message == .hapticFeedback(hapticType: hapticType)) + } + } + + @Test + func handleHapticFeedback() { + let dependencyContainer = DependencyContainer() + let messageHandler = PaywallMessageHandler( + receiptManager: dependencyContainer.receiptManager, + factory: dependencyContainer, + permissionHandler: FakePermissionHandler() + ) + let webView = FakeWebView( + isMac: false, + messageHandler: messageHandler, + isOnDeviceCacheEnabled: true, + factory: dependencyContainer + ) + let delegate = PaywallMessageHandlerDelegateMock( + paywallInfo: .stub(), + webView: webView + ) + messageHandler.delegate = delegate + + // This should not crash and should not trigger any delegate events + messageHandler.handle(.hapticFeedback(hapticType: "medium")) + + // Haptic feedback doesn't trigger delegate events, so we just verify it doesn't crash + #expect(delegate.eventDidOccur == nil) + } }