From 15e16c8d6f948dbbaad46633c784aa3eb1bbc94c Mon Sep 17 00:00:00 2001 From: Dmytro Baryshev Date: Tue, 14 Apr 2026 02:38:25 +0300 Subject: [PATCH 01/10] AB#280444 - gamify widget add GamifyWidgetSDK and view controller --- .../GamifyWidget/GamifyWidgetSDK.swift | 48 ++++++ .../GamifyWidgetViewController.swift | 158 ++++++++++++++++++ .../GamifyWidget/GamifyWidgetSDKTests.swift | 22 +++ 3 files changed, 228 insertions(+) create mode 100644 OptimoveSDK/Sources/Classes/GamifyWidget/GamifyWidgetSDK.swift create mode 100644 OptimoveSDK/Sources/Classes/GamifyWidget/GamifyWidgetViewController.swift create mode 100644 OptimoveSDK/Tests/Sources/GamifyWidget/GamifyWidgetSDKTests.swift diff --git a/OptimoveSDK/Sources/Classes/GamifyWidget/GamifyWidgetSDK.swift b/OptimoveSDK/Sources/Classes/GamifyWidget/GamifyWidgetSDK.swift new file mode 100644 index 00000000..bdf80a01 --- /dev/null +++ b/OptimoveSDK/Sources/Classes/GamifyWidget/GamifyWidgetSDK.swift @@ -0,0 +1,48 @@ +// Copyright © 2024 Optimove. All rights reserved. + +import UIKit + +/// Entry point for the Gamify Widget SDK. +/// +/// Usage: +/// GamifyWidgetSDK.initialize(widgetUrl: "https://your-widget.example.com") +/// GamifyWidgetSDK.open(from: viewController, userId: "u123") +public final class GamifyWidgetSDK { + + internal static var widgetUrl: String = "" + + private init() {} + + /// Configure the widget URL before opening. + public static func initialize(widgetUrl: String) { + self.widgetUrl = widgetUrl + } + + /// Present the widget in a modal sheet. + /// + /// - Parameters: + /// - viewController: The presenting UIViewController. + /// - userId: Optional user ID injected via INIT handshake. + /// - token: Optional auth token injected via INIT handshake. + public static func open( + from viewController: UIViewController, + userId: String? = nil, + token: String? = nil + ) { + let vc = GamifyWidgetViewController( + widgetUrl: widgetUrl, + userId: userId, + token: token + ) + let nav = UINavigationController(rootViewController: vc) + if #available(iOS 15.0, *) { + if let sheet = nav.sheetPresentationController { + sheet.detents = [.large()] + sheet.prefersGrabberVisible = true + } + } else { + nav.modalPresentationStyle = .pageSheet + } + viewController.present(nav, animated: true) + } +} diff --git a/OptimoveSDK/Sources/Classes/GamifyWidget/GamifyWidgetViewController.swift b/OptimoveSDK/Sources/Classes/GamifyWidget/GamifyWidgetViewController.swift new file mode 100644 index 00000000..e195848e --- /dev/null +++ b/OptimoveSDK/Sources/Classes/GamifyWidget/GamifyWidgetViewController.swift @@ -0,0 +1,158 @@ +// Copyright © 2024 Optimove. All rights reserved. + +import UIKit +import WebKit + +private enum BridgeMessage { + static let handlerName = "nativeBridge" +} + +final class GamifyWidgetViewController: UIViewController { + + private let widgetUrl: String + private let userId: String? + private let token: String? + + private var webView: WKWebView! + private var activityIndicator: UIActivityIndicatorView! + private var errorLabel: UILabel! + + init(widgetUrl: String, userId: String?, token: String?) { + self.widgetUrl = widgetUrl + self.userId = userId + self.token = token + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { fatalError() } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .systemBackground + setupNavigationBar() + setupWebView() + setupLoadingIndicator() + setupErrorLabel() + loadWidget() + } + + private func setupNavigationBar() { + navigationItem.rightBarButtonItem = UIBarButtonItem( + barButtonSystemItem: .close, + target: self, + action: #selector(dismissSelf) + ) + } + + private func setupWebView() { + let contentController = WKUserContentController() + contentController.add(self, name: BridgeMessage.handlerName) + + let config = WKWebViewConfiguration() + config.userContentController = contentController + + webView = WKWebView(frame: .zero, configuration: config) + webView.navigationDelegate = self + webView.translatesAutoresizingMaskIntoConstraints = false + + view.addSubview(webView) + NSLayoutConstraint.activate([ + webView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + webView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + webView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + webView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + } + + private func setupLoadingIndicator() { + activityIndicator = UIActivityIndicatorView(style: .medium) + activityIndicator.translatesAutoresizingMaskIntoConstraints = false + activityIndicator.hidesWhenStopped = true + view.addSubview(activityIndicator) + NSLayoutConstraint.activate([ + activityIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor), + activityIndicator.centerYAnchor.constraint(equalTo: view.centerYAnchor), + ]) + activityIndicator.startAnimating() + } + + private func setupErrorLabel() { + errorLabel = UILabel() + errorLabel.text = "Unable to load widget.\nCheck your connection and try again." + errorLabel.numberOfLines = 0 + errorLabel.textAlignment = .center + errorLabel.isHidden = true + errorLabel.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(errorLabel) + NSLayoutConstraint.activate([ + errorLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor), + errorLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor), + errorLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 24), + errorLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -24), + ]) + } + + private func loadWidget() { + guard let url = URL(string: widgetUrl) else { + showError() + return + } + webView.load(URLRequest(url: url)) + } + + private func sendInit() { + var payload: [String: Any] = ["type": "INIT"] + if let userId = userId { payload["userId"] = userId } + if let token = token { payload["token"] = token } + guard let data = try? JSONSerialization.data(withJSONObject: payload), + let json = String(data: data, encoding: .utf8) else { return } + Logger.debug("GamifyWidget sending INIT: \(redactedLog(payload))") + webView.evaluateJavaScript("window.postMessage(\(json), '*');", completionHandler: nil) + } + + private func redactedLog(_ payload: [String: Any]) -> String { + var redacted = payload + if redacted["token"] != nil { redacted["token"] = "[REDACTED]" } + return "\(redacted)" + } + + private func showError() { + DispatchQueue.main.async { + self.activityIndicator.stopAnimating() + self.webView.isHidden = true + self.errorLabel.isHidden = false + } + } + + @objc private func dismissSelf() { + dismiss(animated: true) + } +} + +extension GamifyWidgetViewController: WKScriptMessageHandler { + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + guard message.name == BridgeMessage.handlerName, + let body = message.body as? [String: Any], + let type = body["type"] as? String else { return } + + if type == "READY" { + DispatchQueue.main.async { self.sendInit() } + } else if type == "CLOSE" { + DispatchQueue.main.async { self.dismissSelf() } + } + } +} + +extension GamifyWidgetViewController: WKNavigationDelegate { + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + activityIndicator.stopAnimating() + } + + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { + showError() + } + + func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { + showError() + } +} diff --git a/OptimoveSDK/Tests/Sources/GamifyWidget/GamifyWidgetSDKTests.swift b/OptimoveSDK/Tests/Sources/GamifyWidget/GamifyWidgetSDKTests.swift new file mode 100644 index 00000000..8c9c60b5 --- /dev/null +++ b/OptimoveSDK/Tests/Sources/GamifyWidget/GamifyWidgetSDKTests.swift @@ -0,0 +1,22 @@ +import XCTest +@testable import OptimoveSDK + +final class GamifyWidgetSDKTests: XCTestCase { + + override func setUp() { + super.setUp() + GamifyWidgetSDK.initialize(widgetUrl: "") + } + + func testInitializeSetsWidgetUrl() { + let url = "https://gamify-widget.example.com" + GamifyWidgetSDK.initialize(widgetUrl: url) + XCTAssertEqual(GamifyWidgetSDK.widgetUrl, url) + } + + func testInitializeOverwritesPreviousUrl() { + GamifyWidgetSDK.initialize(widgetUrl: "https://first.example.com") + GamifyWidgetSDK.initialize(widgetUrl: "https://second.example.com") + XCTAssertEqual(GamifyWidgetSDK.widgetUrl, "https://second.example.com") + } +} From b090425a8d463e4ed10aa19e8c23e2836c01d2c2 Mon Sep 17 00:00:00 2001 From: Dmytro Baryshev Date: Tue, 14 Apr 2026 02:45:07 +0300 Subject: [PATCH 02/10] AB#280444 - gamify widget fix iOS bridge handler names to match widget JS --- .../GamifyWidget/GamifyWidgetViewController.swift | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/OptimoveSDK/Sources/Classes/GamifyWidget/GamifyWidgetViewController.swift b/OptimoveSDK/Sources/Classes/GamifyWidget/GamifyWidgetViewController.swift index e195848e..769a30f1 100644 --- a/OptimoveSDK/Sources/Classes/GamifyWidget/GamifyWidgetViewController.swift +++ b/OptimoveSDK/Sources/Classes/GamifyWidget/GamifyWidgetViewController.swift @@ -4,7 +4,8 @@ import UIKit import WebKit private enum BridgeMessage { - static let handlerName = "nativeBridge" + static let receiveMessage = "receiveMessage" + static let closeWidget = "closeWidget" } final class GamifyWidgetViewController: UIViewController { @@ -46,7 +47,8 @@ final class GamifyWidgetViewController: UIViewController { private func setupWebView() { let contentController = WKUserContentController() - contentController.add(self, name: BridgeMessage.handlerName) + contentController.add(self, name: BridgeMessage.receiveMessage) + contentController.add(self, name: BridgeMessage.closeWidget) let config = WKWebViewConfiguration() config.userContentController = contentController @@ -131,14 +133,15 @@ final class GamifyWidgetViewController: UIViewController { extension GamifyWidgetViewController: WKScriptMessageHandler { func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { - guard message.name == BridgeMessage.handlerName, + if message.name == BridgeMessage.closeWidget { + DispatchQueue.main.async { self.dismissSelf() } + return + } + guard message.name == BridgeMessage.receiveMessage, let body = message.body as? [String: Any], let type = body["type"] as? String else { return } - if type == "READY" { DispatchQueue.main.async { self.sendInit() } - } else if type == "CLOSE" { - DispatchQueue.main.async { self.dismissSelf() } } } } From 36b6a0b3ade0361971bb456468704e5910c34874 Mon Sep 17 00:00:00 2001 From: Dmytro Baryshev Date: Mon, 27 Apr 2026 14:22:07 +0300 Subject: [PATCH 03/10] AB#280444 - gamify widget fix iOS 12 availability for UIKit APIs Co-Authored-By: Claude Sonnet 4.6 --- .../GamifyWidgetViewController.swift | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/OptimoveSDK/Sources/Classes/GamifyWidget/GamifyWidgetViewController.swift b/OptimoveSDK/Sources/Classes/GamifyWidget/GamifyWidgetViewController.swift index 769a30f1..7bee94b5 100644 --- a/OptimoveSDK/Sources/Classes/GamifyWidget/GamifyWidgetViewController.swift +++ b/OptimoveSDK/Sources/Classes/GamifyWidget/GamifyWidgetViewController.swift @@ -29,7 +29,11 @@ final class GamifyWidgetViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() - view.backgroundColor = .systemBackground + if #available(iOS 13.0, *) { + view.backgroundColor = .systemBackground + } else { + view.backgroundColor = .white + } setupNavigationBar() setupWebView() setupLoadingIndicator() @@ -38,11 +42,13 @@ final class GamifyWidgetViewController: UIViewController { } private func setupNavigationBar() { - navigationItem.rightBarButtonItem = UIBarButtonItem( - barButtonSystemItem: .close, - target: self, - action: #selector(dismissSelf) - ) + let closeItem: UIBarButtonItem + if #available(iOS 13.0, *) { + closeItem = UIBarButtonItem(barButtonSystemItem: .close, target: self, action: #selector(dismissSelf)) + } else { + closeItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(dismissSelf)) + } + navigationItem.rightBarButtonItem = closeItem } private func setupWebView() { @@ -67,7 +73,11 @@ final class GamifyWidgetViewController: UIViewController { } private func setupLoadingIndicator() { - activityIndicator = UIActivityIndicatorView(style: .medium) + if #available(iOS 13.0, *) { + activityIndicator = UIActivityIndicatorView(style: .medium) + } else { + activityIndicator = UIActivityIndicatorView(style: .gray) + } activityIndicator.translatesAutoresizingMaskIntoConstraints = false activityIndicator.hidesWhenStopped = true view.addSubview(activityIndicator) From da1effe867a2077597f05340384f26bcee4c1997 Mon Sep 17 00:00:00 2001 From: Dmytro Baryshev Date: Tue, 28 Apr 2026 01:27:33 +0300 Subject: [PATCH 04/10] AB#280444 - gamify widget present vc directly without nav controller wrapper Co-Authored-By: Claude Sonnet 4.6 --- .../Classes/GamifyWidget/GamifyWidgetSDK.swift | 7 +++---- .../GamifyWidget/GamifyWidgetViewController.swift | 11 ----------- 2 files changed, 3 insertions(+), 15 deletions(-) diff --git a/OptimoveSDK/Sources/Classes/GamifyWidget/GamifyWidgetSDK.swift b/OptimoveSDK/Sources/Classes/GamifyWidget/GamifyWidgetSDK.swift index bdf80a01..e0726c42 100644 --- a/OptimoveSDK/Sources/Classes/GamifyWidget/GamifyWidgetSDK.swift +++ b/OptimoveSDK/Sources/Classes/GamifyWidget/GamifyWidgetSDK.swift @@ -34,15 +34,14 @@ public final class GamifyWidgetSDK { userId: userId, token: token ) - let nav = UINavigationController(rootViewController: vc) if #available(iOS 15.0, *) { - if let sheet = nav.sheetPresentationController { + if let sheet = vc.sheetPresentationController { sheet.detents = [.large()] sheet.prefersGrabberVisible = true } } else { - nav.modalPresentationStyle = .pageSheet + vc.modalPresentationStyle = .pageSheet } - viewController.present(nav, animated: true) + viewController.present(vc, animated: true) } } diff --git a/OptimoveSDK/Sources/Classes/GamifyWidget/GamifyWidgetViewController.swift b/OptimoveSDK/Sources/Classes/GamifyWidget/GamifyWidgetViewController.swift index 7bee94b5..d8645880 100644 --- a/OptimoveSDK/Sources/Classes/GamifyWidget/GamifyWidgetViewController.swift +++ b/OptimoveSDK/Sources/Classes/GamifyWidget/GamifyWidgetViewController.swift @@ -34,23 +34,12 @@ final class GamifyWidgetViewController: UIViewController { } else { view.backgroundColor = .white } - setupNavigationBar() setupWebView() setupLoadingIndicator() setupErrorLabel() loadWidget() } - private func setupNavigationBar() { - let closeItem: UIBarButtonItem - if #available(iOS 13.0, *) { - closeItem = UIBarButtonItem(barButtonSystemItem: .close, target: self, action: #selector(dismissSelf)) - } else { - closeItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(dismissSelf)) - } - navigationItem.rightBarButtonItem = closeItem - } - private func setupWebView() { let contentController = WKUserContentController() contentController.add(self, name: BridgeMessage.receiveMessage) From 7bbab3c1f3117e4efff85f84280ef015565710c0 Mon Sep 17 00:00:00 2001 From: Dmytro Baryshev Date: Mon, 4 May 2026 16:37:45 +0300 Subject: [PATCH 05/10] AB#280444 - gamify widget address PR review comments Co-Authored-By: Claude Sonnet 4.6 --- .../Sources/Classes/GamifyWidget/GamifyWidgetSDK.swift | 2 +- .../Classes/GamifyWidget/GamifyWidgetViewController.swift | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/OptimoveSDK/Sources/Classes/GamifyWidget/GamifyWidgetSDK.swift b/OptimoveSDK/Sources/Classes/GamifyWidget/GamifyWidgetSDK.swift index e0726c42..7eed23ab 100644 --- a/OptimoveSDK/Sources/Classes/GamifyWidget/GamifyWidgetSDK.swift +++ b/OptimoveSDK/Sources/Classes/GamifyWidget/GamifyWidgetSDK.swift @@ -1,4 +1,4 @@ -// Copyright © 2024 Optimove. All rights reserved. +// Copyright © 2026 Optimove. All rights reserved. import UIKit diff --git a/OptimoveSDK/Sources/Classes/GamifyWidget/GamifyWidgetViewController.swift b/OptimoveSDK/Sources/Classes/GamifyWidget/GamifyWidgetViewController.swift index d8645880..e98d2061 100644 --- a/OptimoveSDK/Sources/Classes/GamifyWidget/GamifyWidgetViewController.swift +++ b/OptimoveSDK/Sources/Classes/GamifyWidget/GamifyWidgetViewController.swift @@ -1,4 +1,4 @@ -// Copyright © 2024 Optimove. All rights reserved. +// Copyright © 2026 Optimove. All rights reserved. import UIKit import WebKit @@ -57,7 +57,7 @@ final class GamifyWidgetViewController: UIViewController { webView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), webView.leadingAnchor.constraint(equalTo: view.leadingAnchor), webView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - webView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + webView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), ]) } @@ -125,7 +125,7 @@ final class GamifyWidgetViewController: UIViewController { } } - @objc private func dismissSelf() { + private func dismissSelf() { dismiss(animated: true) } } From 9a145a129973a25a764b577110a7a17b136059b6 Mon Sep 17 00:00:00 2001 From: Dmytro Baryshev Date: Tue, 5 May 2026 18:00:49 +0300 Subject: [PATCH 06/10] AB#280444 - gamify widget assert main thread on SDK entry points Co-Authored-By: Claude Sonnet 4.6 --- .../Sources/Classes/GamifyWidget/GamifyWidgetSDK.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/OptimoveSDK/Sources/Classes/GamifyWidget/GamifyWidgetSDK.swift b/OptimoveSDK/Sources/Classes/GamifyWidget/GamifyWidgetSDK.swift index 7eed23ab..f8a76903 100644 --- a/OptimoveSDK/Sources/Classes/GamifyWidget/GamifyWidgetSDK.swift +++ b/OptimoveSDK/Sources/Classes/GamifyWidget/GamifyWidgetSDK.swift @@ -15,6 +15,7 @@ public final class GamifyWidgetSDK { /// Configure the widget URL before opening. public static func initialize(widgetUrl: String) { + assertOnMainThread() self.widgetUrl = widgetUrl } @@ -29,6 +30,7 @@ public final class GamifyWidgetSDK { userId: String? = nil, token: String? = nil ) { + assertOnMainThread() let vc = GamifyWidgetViewController( widgetUrl: widgetUrl, userId: userId, @@ -44,4 +46,8 @@ public final class GamifyWidgetSDK { } viewController.present(vc, animated: true) } + + private static func assertOnMainThread(_ message: String = "Must be on main thread") { + assert(Thread.isMainThread, message) + } } From 13783a46490e39b95fecf5e0d3d3fccb4873f86a Mon Sep 17 00:00:00 2001 From: Dmytro Baryshev Date: Mon, 11 May 2026 14:20:02 +0300 Subject: [PATCH 07/10] AB#280444 - gamify widget auto-dispatch SDK entry points to main thread Match the ensureMain + assertOnMainThread pattern from InAppPresenter.swift so public entry points dispatch to main themselves instead of relying on a debug-only assert that strips in release builds. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../GamifyWidget/GamifyWidgetSDK.swift | 20 +++++++++++++++++++ .../GamifyWidget/GamifyWidgetSDKTests.swift | 13 ++++++++++++ 2 files changed, 33 insertions(+) diff --git a/OptimoveSDK/Sources/Classes/GamifyWidget/GamifyWidgetSDK.swift b/OptimoveSDK/Sources/Classes/GamifyWidget/GamifyWidgetSDK.swift index f8a76903..6602bd20 100644 --- a/OptimoveSDK/Sources/Classes/GamifyWidget/GamifyWidgetSDK.swift +++ b/OptimoveSDK/Sources/Classes/GamifyWidget/GamifyWidgetSDK.swift @@ -15,6 +15,10 @@ public final class GamifyWidgetSDK { /// Configure the widget URL before opening. public static func initialize(widgetUrl: String) { + ensureMain { initialize_onMain(widgetUrl: widgetUrl) } + } + + private static func initialize_onMain(widgetUrl: String) { assertOnMainThread() self.widgetUrl = widgetUrl } @@ -29,6 +33,14 @@ public final class GamifyWidgetSDK { from viewController: UIViewController, userId: String? = nil, token: String? = nil + ) { + ensureMain { open_onMain(from: viewController, userId: userId, token: token) } + } + + private static func open_onMain( + from viewController: UIViewController, + userId: String? = nil, + token: String? = nil ) { assertOnMainThread() let vc = GamifyWidgetViewController( @@ -47,6 +59,14 @@ public final class GamifyWidgetSDK { viewController.present(vc, animated: true) } + private static func ensureMain(_ work: @escaping () -> Void) { + if Thread.isMainThread { + work() + } else { + DispatchQueue.main.async(execute: work) + } + } + private static func assertOnMainThread(_ message: String = "Must be on main thread") { assert(Thread.isMainThread, message) } diff --git a/OptimoveSDK/Tests/Sources/GamifyWidget/GamifyWidgetSDKTests.swift b/OptimoveSDK/Tests/Sources/GamifyWidget/GamifyWidgetSDKTests.swift index 8c9c60b5..8f796c08 100644 --- a/OptimoveSDK/Tests/Sources/GamifyWidget/GamifyWidgetSDKTests.swift +++ b/OptimoveSDK/Tests/Sources/GamifyWidget/GamifyWidgetSDKTests.swift @@ -19,4 +19,17 @@ final class GamifyWidgetSDKTests: XCTestCase { GamifyWidgetSDK.initialize(widgetUrl: "https://second.example.com") XCTAssertEqual(GamifyWidgetSDK.widgetUrl, "https://second.example.com") } + + func testInitializeFromBackgroundThreadDispatchesToMain() { + let url = "https://background.example.com" + let expectation = expectation(description: "widgetUrl set from background call") + DispatchQueue.global().async { + GamifyWidgetSDK.initialize(widgetUrl: url) + DispatchQueue.main.async { + XCTAssertEqual(GamifyWidgetSDK.widgetUrl, url) + expectation.fulfill() + } + } + wait(for: [expectation], timeout: 2.0) + } } From 3719c18361e2b599459fa533df8904e31d7b4942 Mon Sep 17 00:00:00 2001 From: Dmytro Baryshev Date: Mon, 18 May 2026 15:34:59 +0300 Subject: [PATCH 08/10] AB#280444 - gamify widget fail-fast on invalid widgetUrl, drop in-view error label Match the existing presenter pattern from InAppPresenter/OverlayMessagingPresenter: - GamifyWidgetSDK.open pre-flight guards empty/invalid widgetUrl with Logger.error + return - Remove the error label from GamifyWidgetViewController; nav failures silently dismiss Co-Authored-By: Claude Opus 4.7 (1M context) --- .../GamifyWidget/GamifyWidgetSDK.swift | 4 +++ .../GamifyWidgetViewController.swift | 36 +++---------------- 2 files changed, 9 insertions(+), 31 deletions(-) diff --git a/OptimoveSDK/Sources/Classes/GamifyWidget/GamifyWidgetSDK.swift b/OptimoveSDK/Sources/Classes/GamifyWidget/GamifyWidgetSDK.swift index 6602bd20..6bfa9459 100644 --- a/OptimoveSDK/Sources/Classes/GamifyWidget/GamifyWidgetSDK.swift +++ b/OptimoveSDK/Sources/Classes/GamifyWidget/GamifyWidgetSDK.swift @@ -43,6 +43,10 @@ public final class GamifyWidgetSDK { token: String? = nil ) { assertOnMainThread() + guard !widgetUrl.isEmpty, URL(string: widgetUrl) != nil else { + Logger.error("GamifyWidgetSDK.open called with an invalid widgetUrl.") + return + } let vc = GamifyWidgetViewController( widgetUrl: widgetUrl, userId: userId, diff --git a/OptimoveSDK/Sources/Classes/GamifyWidget/GamifyWidgetViewController.swift b/OptimoveSDK/Sources/Classes/GamifyWidget/GamifyWidgetViewController.swift index e98d2061..a888da25 100644 --- a/OptimoveSDK/Sources/Classes/GamifyWidget/GamifyWidgetViewController.swift +++ b/OptimoveSDK/Sources/Classes/GamifyWidget/GamifyWidgetViewController.swift @@ -16,7 +16,6 @@ final class GamifyWidgetViewController: UIViewController { private var webView: WKWebView! private var activityIndicator: UIActivityIndicatorView! - private var errorLabel: UILabel! init(widgetUrl: String, userId: String?, token: String?) { self.widgetUrl = widgetUrl @@ -36,7 +35,6 @@ final class GamifyWidgetViewController: UIViewController { } setupWebView() setupLoadingIndicator() - setupErrorLabel() loadWidget() } @@ -77,25 +75,9 @@ final class GamifyWidgetViewController: UIViewController { activityIndicator.startAnimating() } - private func setupErrorLabel() { - errorLabel = UILabel() - errorLabel.text = "Unable to load widget.\nCheck your connection and try again." - errorLabel.numberOfLines = 0 - errorLabel.textAlignment = .center - errorLabel.isHidden = true - errorLabel.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(errorLabel) - NSLayoutConstraint.activate([ - errorLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor), - errorLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor), - errorLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 24), - errorLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -24), - ]) - } - private func loadWidget() { guard let url = URL(string: widgetUrl) else { - showError() + dismissSelf() return } webView.load(URLRequest(url: url)) @@ -117,14 +99,6 @@ final class GamifyWidgetViewController: UIViewController { return "\(redacted)" } - private func showError() { - DispatchQueue.main.async { - self.activityIndicator.stopAnimating() - self.webView.isHidden = true - self.errorLabel.isHidden = false - } - } - private func dismissSelf() { dismiss(animated: true) } @@ -150,11 +124,11 @@ extension GamifyWidgetViewController: WKNavigationDelegate { activityIndicator.stopAnimating() } - func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { - showError() + func webView(_: WKWebView, didFail _: WKNavigation!, withError _: Error) { + dismissSelf() } - func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { - showError() + func webView(_: WKWebView, didFailProvisionalNavigation _: WKNavigation!, withError _: Error) { + dismissSelf() } } From d9099e7da32259707da667475ce2f22d7181fabb Mon Sep 17 00:00:00 2001 From: Dmytro Baryshev Date: Mon, 18 May 2026 16:04:13 +0300 Subject: [PATCH 09/10] AB#280444 - gamify widget force initialize calls onto main in tests Wrap GamifyWidgetSDK.initialize(widgetUrl:) calls in runOnMainSync helper so the write completes before assertions, removing the false-negative risk under off-main test execution. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../GamifyWidget/GamifyWidgetSDKTests.swift | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/OptimoveSDK/Tests/Sources/GamifyWidget/GamifyWidgetSDKTests.swift b/OptimoveSDK/Tests/Sources/GamifyWidget/GamifyWidgetSDKTests.swift index 8f796c08..13991e98 100644 --- a/OptimoveSDK/Tests/Sources/GamifyWidget/GamifyWidgetSDKTests.swift +++ b/OptimoveSDK/Tests/Sources/GamifyWidget/GamifyWidgetSDKTests.swift @@ -3,20 +3,34 @@ import XCTest final class GamifyWidgetSDKTests: XCTestCase { + private func runOnMainSync(_ block: @escaping () -> Void) { + if Thread.isMainThread { + block() + } else { + DispatchQueue.main.sync(execute: block) + } + } + override func setUp() { super.setUp() - GamifyWidgetSDK.initialize(widgetUrl: "") + runOnMainSync { + GamifyWidgetSDK.initialize(widgetUrl: "") + } } func testInitializeSetsWidgetUrl() { let url = "https://gamify-widget.example.com" - GamifyWidgetSDK.initialize(widgetUrl: url) + runOnMainSync { + GamifyWidgetSDK.initialize(widgetUrl: url) + } XCTAssertEqual(GamifyWidgetSDK.widgetUrl, url) } func testInitializeOverwritesPreviousUrl() { - GamifyWidgetSDK.initialize(widgetUrl: "https://first.example.com") - GamifyWidgetSDK.initialize(widgetUrl: "https://second.example.com") + runOnMainSync { + GamifyWidgetSDK.initialize(widgetUrl: "https://first.example.com") + GamifyWidgetSDK.initialize(widgetUrl: "https://second.example.com") + } XCTAssertEqual(GamifyWidgetSDK.widgetUrl, "https://second.example.com") } From bc665a6c285ae617e1d2dde733ad3bfd10041b04 Mon Sep 17 00:00:00 2001 From: Dmytro Baryshev Date: Mon, 18 May 2026 19:18:39 +0300 Subject: [PATCH 10/10] AB#280444 - gamify widget bump SDK version to 6.7.0 Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 4 ++++ OptimoveCore.podspec | 2 +- OptimoveCore/Sources/Classes/Constants/SDKVersion.swift | 2 +- OptimoveNotificationServiceExtension.podspec | 2 +- OptimoveSDK.podspec | 2 +- 5 files changed, 8 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e6840f1..7994a2ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 6.7.0 + +- Add Gamify Widget SDK — `GamifyWidgetSDK.initialize(widgetUrl:)` and `GamifyWidgetSDK.open(from:userId:token:)` present the widget in a modal `WKWebView` with a JS bridge for `READY` / `INIT` / `CLOSE` handshakes. + ## 6.6.0 - Implementation for Overlay Messaging channel. Check optimove developer docs for more. diff --git a/OptimoveCore.podspec b/OptimoveCore.podspec index f03657b3..916cae46 100644 --- a/OptimoveCore.podspec +++ b/OptimoveCore.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'OptimoveCore' - s.version = '6.6.0' + s.version = '6.7.0' s.summary = 'Official Optimove SDK for iOS. Core framework.' s.description = 'The core framework is used to share code-base between other Optimove frameworks.' s.homepage = 'https://github.com/optimove-tech/Optimove-SDK-iOS' diff --git a/OptimoveCore/Sources/Classes/Constants/SDKVersion.swift b/OptimoveCore/Sources/Classes/Constants/SDKVersion.swift index db7ff256..262886dc 100644 --- a/OptimoveCore/Sources/Classes/Constants/SDKVersion.swift +++ b/OptimoveCore/Sources/Classes/Constants/SDKVersion.swift @@ -1,3 +1,3 @@ // Copyright © 2019 Optimove. All rights reserved. -public let SDKVersion = "6.6.0" +public let SDKVersion = "6.7.0" diff --git a/OptimoveNotificationServiceExtension.podspec b/OptimoveNotificationServiceExtension.podspec index 4cbd8c7d..dec859d9 100644 --- a/OptimoveNotificationServiceExtension.podspec +++ b/OptimoveNotificationServiceExtension.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'OptimoveNotificationServiceExtension' - s.version = '6.6.0' + s.version = '6.7.0' s.summary = 'Official Optimove SDK for iOS. Notification service extension framework.' s.description = 'The notification service extension is used for handling additional content in push notifications.' s.homepage = 'https://github.com/optimove-tech/Optimove-SDK-iOS' diff --git a/OptimoveSDK.podspec b/OptimoveSDK.podspec index 25878c00..68529101 100644 --- a/OptimoveSDK.podspec +++ b/OptimoveSDK.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'OptimoveSDK' - s.version = '6.6.0' + s.version = '6.7.0' s.summary = 'Official Optimove SDK for iOS.' s.description = 'The Optimove SDK framework is used for reporting events and receive push notifications.' s.homepage = 'https://github.com/optimove-tech/Optimove-SDK-iOS'