Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
2 changes: 1 addition & 1 deletion OptimoveCore.podspec
Original file line number Diff line number Diff line change
@@ -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'
Expand Down
2 changes: 1 addition & 1 deletion OptimoveCore/Sources/Classes/Constants/SDKVersion.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
// Copyright © 2019 Optimove. All rights reserved.

public let SDKVersion = "6.6.0"
public let SDKVersion = "6.7.0"
2 changes: 1 addition & 1 deletion OptimoveNotificationServiceExtension.podspec
Original file line number Diff line number Diff line change
@@ -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'
Expand Down
2 changes: 1 addition & 1 deletion OptimoveSDK.podspec
Original file line number Diff line number Diff line change
@@ -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'
Expand Down
77 changes: 77 additions & 0 deletions OptimoveSDK/Sources/Classes/GamifyWidget/GamifyWidgetSDK.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
// Copyright © 2026 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 = ""
Comment thread
dmytro-b-optimove marked this conversation as resolved.

private init() {}

/// 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
}

/// 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
) {
ensureMain { open_onMain(from: viewController, userId: userId, token: token) }
}

private static func open_onMain(
from viewController: UIViewController,
userId: String? = nil,
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,
token: token
)
Comment thread
dmytro-b-optimove marked this conversation as resolved.
if #available(iOS 15.0, *) {
if let sheet = vc.sheetPresentationController {
sheet.detents = [.large()]
sheet.prefersGrabberVisible = true
}
} else {
vc.modalPresentationStyle = .pageSheet
}
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)
Comment thread
eligutovsky marked this conversation as resolved.
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
// Copyright © 2026 Optimove. All rights reserved.

import UIKit
import WebKit
Comment thread
dmytro-b-optimove marked this conversation as resolved.

private enum BridgeMessage {
static let receiveMessage = "receiveMessage"
static let closeWidget = "closeWidget"
}
Comment thread
dmytro-b-optimove marked this conversation as resolved.

final class GamifyWidgetViewController: UIViewController {

private let widgetUrl: String
private let userId: String?
private let token: String?

private var webView: WKWebView!
private var activityIndicator: UIActivityIndicatorView!

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()
if #available(iOS 13.0, *) {
view.backgroundColor = .systemBackground
} else {
view.backgroundColor = .white
}
setupWebView()
setupLoadingIndicator()
loadWidget()
}

private func setupWebView() {
let contentController = WKUserContentController()
contentController.add(self, name: BridgeMessage.receiveMessage)
Comment thread
graciecooper marked this conversation as resolved.
contentController.add(self, name: BridgeMessage.closeWidget)

let config = WKWebViewConfiguration()
config.userContentController = contentController

Comment thread
dmytro-b-optimove marked this conversation as resolved.
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.safeAreaLayoutGuide.bottomAnchor),
])
}

private func setupLoadingIndicator() {
if #available(iOS 13.0, *) {
activityIndicator = UIActivityIndicatorView(style: .medium)
} else {
activityIndicator = UIActivityIndicatorView(style: .gray)
}
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 loadWidget() {
guard let url = URL(string: widgetUrl) else {
dismissSelf()
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 dismissSelf() {
dismiss(animated: true)
}
}

extension GamifyWidgetViewController: WKScriptMessageHandler {
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
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() }
}
}
}

extension GamifyWidgetViewController: WKNavigationDelegate {
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
activityIndicator.stopAnimating()
}

func webView(_: WKWebView, didFail _: WKNavigation!, withError _: Error) {
dismissSelf()
}

func webView(_: WKWebView, didFailProvisionalNavigation _: WKNavigation!, withError _: Error) {
dismissSelf()
}
}
49 changes: 49 additions & 0 deletions OptimoveSDK/Tests/Sources/GamifyWidget/GamifyWidgetSDKTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import XCTest
@testable import OptimoveSDK

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()
runOnMainSync {
GamifyWidgetSDK.initialize(widgetUrl: "")
}
}

func testInitializeSetsWidgetUrl() {
let url = "https://gamify-widget.example.com"
runOnMainSync {
GamifyWidgetSDK.initialize(widgetUrl: url)
}
XCTAssertEqual(GamifyWidgetSDK.widgetUrl, url)
}

func testInitializeOverwritesPreviousUrl() {
runOnMainSync {
GamifyWidgetSDK.initialize(widgetUrl: "https://first.example.com")
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)
}
}