diff --git a/Engage-swift.podspec b/Engage-swift.podspec index d888ea0..df5181b 100644 --- a/Engage-swift.podspec +++ b/Engage-swift.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'Engage-swift' - s.version = '0.2.0' + s.version = '0.3.0' s.module_name = 'Engage' s.summary = 'Official Engage SDK for iOS.' s.homepage = 'https://engage.so/' diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..0c72347 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,122 @@ +{ + "pins" : [ + { + "identity" : "abseil-cpp-binary", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/abseil-cpp-binary.git", + "state" : { + "revision" : "194a6706acbd25e4ef639bcaddea16e8758a3e27", + "version" : "1.2024011602.0" + } + }, + { + "identity" : "app-check", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/app-check.git", + "state" : { + "revision" : "3b62f154d00019ae29a71e9738800bb6f18b236d", + "version" : "10.19.2" + } + }, + { + "identity" : "firebase-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/firebase-ios-sdk", + "state" : { + "revision" : "eca84fd638116dd6adb633b5a3f31cc7befcbb7d", + "version" : "10.29.0" + } + }, + { + "identity" : "googleappmeasurement", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleAppMeasurement.git", + "state" : { + "revision" : "fe727587518729046fc1465625b9afd80b5ab361", + "version" : "10.28.0" + } + }, + { + "identity" : "googledatatransport", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleDataTransport.git", + "state" : { + "revision" : "a637d318ae7ae246b02d7305121275bc75ed5565", + "version" : "9.4.0" + } + }, + { + "identity" : "googleutilities", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleUtilities.git", + "state" : { + "revision" : "57a1d307f42df690fdef2637f3e5b776da02aad6", + "version" : "7.13.3" + } + }, + { + "identity" : "grpc-binary", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/grpc-binary.git", + "state" : { + "revision" : "e9fad491d0673bdda7063a0341fb6b47a30c5359", + "version" : "1.62.2" + } + }, + { + "identity" : "gtm-session-fetcher", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/gtm-session-fetcher.git", + "state" : { + "revision" : "a2ab612cb980066ee56d90d60d8462992c07f24b", + "version" : "3.5.0" + } + }, + { + "identity" : "interop-ios-for-google-sdks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/interop-ios-for-google-sdks.git", + "state" : { + "revision" : "2d12673670417654f08f5f90fdd62926dc3a2648", + "version" : "100.0.0" + } + }, + { + "identity" : "leveldb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/leveldb.git", + "state" : { + "revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1", + "version" : "1.22.5" + } + }, + { + "identity" : "nanopb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/nanopb.git", + "state" : { + "revision" : "b7e1104502eca3a213b46303391ca4d3bc8ddec1", + "version" : "2.30910.0" + } + }, + { + "identity" : "promises", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/promises.git", + "state" : { + "revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac", + "version" : "2.4.0" + } + }, + { + "identity" : "swift-protobuf", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-protobuf.git", + "state" : { + "revision" : "edb6ed4919f7756157fe02f2552b7e3850a538e5", + "version" : "1.28.1" + } + } + ], + "version" : 2 +} diff --git a/Package.swift b/Package.swift index f014579..f37d46f 100644 --- a/Package.swift +++ b/Package.swift @@ -12,11 +12,17 @@ let package = Package( name: "Engage", targets: ["Engage"]), ], + dependencies: [ + .package(url: "https://github.com/firebase/firebase-ios-sdk.git", "8.7.0"..<"12.0.0"), + ], targets: [ // Targets are the basic building blocks of a package, defining a module or a test suite. // Targets can depend on other targets in this package and products from dependencies. .target( name: "Engage", + dependencies: [ + .product(name: "FirebaseMessaging", package: "firebase-ios-sdk") + ], path: "Sources" ), .testTarget( diff --git a/README.md b/README.md new file mode 100644 index 0000000..8a41c83 --- /dev/null +++ b/README.md @@ -0,0 +1,142 @@ +# Engage iOS SDK + +[Engage](https://engage.so/) helps businesses deliver personalized customer messaging and marketing automation through email, SMS and in-app messaging. This iOS SDK makes it easy to identify customers, sync customer data (attributes, events and device tokens) to the Engage dashboard and send in-app messages to customers. + +## Features + +- Track device token +- Identify users +- Update user attributes +- Track user events + +## Getting started + +- [Create an Engage account](https://engage.so/) and set up an account to get your public API key. +- Learn about [connecting customer data](https://engage.so/docs/guides/connecting-user-data) to Engage. + +## Installation + +The SDK is available via SPM or Cocoapods. + +### Swift Package Manager (SPM) + +1. In Xcode, go to your project’s **Package Dependencies** section. +2. Click the **+** button to add a new package. +3. Enter the following URL: + +``` +https://github.com/engage-so/engage-ios.git +``` + +4. Choose the version you want to install, and add it to your project. + +### CocoaPods + +1. Add the Engage SDK to your `Podfile`: + +```ruby +platform :ios, '13.0' + +target 'YourAppTarget' do + use_frameworks! + + pod 'Engage-swift', :git => 'https://github.com/engage-so/engage-ios.git', :tag => 'v1.0.0' +end +``` + +2. Install the dependencies by running: + +```bash +pod install +``` + +#### Example of Dependency Declaration in `Package.swift` + +If you are using Swift Package Manager programmatically in a `Package.swift` file: + +```swift +dependencies: [ + .package(url: "https://github.com/engage-so/engage-ios.git", from: "1.0.0") +] +``` + +## Initialization + +Import `Engage` and initialize the SDK. + +```swift +// ... +import Engage + +@main +struct MainApp: App { + init() { + Engage.shared.initialise(publicKey: "public-api-key") + } + // ... +} +``` + +## Identify users + +Engage uses your user's unique identifier (this is mostly the ID field of the users' table) for data tracking. **Identify** lets you link this ID to the user. With identify, you are able to supply more details about the user. + +```swift +let properties = ["first_name": "Jane", "last_name": "Doe", "last_login": Date()] +Engage.shared.identify(uid: "user-id", properties: properties) +``` + +Engage supports the following standard attributes: `first_name`, `last_name`, `email`, `number` (customer's phone number) but you can use identify to add any customer attribute you want. `last_login` in the example above is an example. + +When new users are identified, Engage assumes their signup date to be the current timestamp. You can change this by adding a `created_at` attribute. + +```swift +let properties = ["first_name": "Jane", "last_name": "Doe", "created_at": "2021-01-04"] +Engage.shared.identify(uid: "user-id", properties: properties) +``` + +## Add attributes + +To add more attributes to the user's profile, use the `addAttributes` method. + +```swift +let attributes = ["plan": "Pro", "age": 14] +Engage.shared.addAttributes(properties: attributes, uid: "optional") +``` + +## Set device token + +Engage integrates with [FCM](https://firebase.google.com/docs/cloud-messaging) to let you send push notifications to your users, either through broadcast or automation. However, to do this, you need to send the user's FCM registration token to Engage. The device registration token is a unique identifier that allows the device receive messages. + +```swift +func onNewToken(token: String) { + Engage.shared.setDeviceToken(deviceToken: token, uid: "optional") +} +``` + +## Track events + +Track an event: + +```swift +Engage.shared.track(event: "Login", uid: "optional") +``` + +Track an event with a value: + +```swift +Engage.shared.track(event: "Clicked", value: "Login button", uid: "optional") +``` + +Track an event with properties: + +```swift +let properties = ["type": "button", "counter": counter] +Engage.shared.track(event: "Clicked", value: properties, uid: "optional") +``` + +Engage sets the event date to the current timestamp but if you would like to set a different date, you can add a date as an argument in the `track` method. + +```swift +Engage.shared.track(event: "Clicked", value: "Login button", date: Date(), uid: "optional") +``` diff --git a/Sources/Engage/Engage.swift b/Sources/Engage/Engage.swift index d39edc5..9b45eb5 100644 --- a/Sources/Engage/Engage.swift +++ b/Sources/Engage/Engage.swift @@ -11,10 +11,10 @@ public final class Engage: EngageProtocol { static public let shared = Engage() private func userId(uid: String?) -> String { - let id = uid ?? UserDefaults.standard.value(forKey: "uid") as? String + let id = uid ?? UserDefaults.standard.value(forKey: Constants.uid) as? String guard id != nil else { let anonymous = UUID().uuidString - UserDefaults.standard.setValue(anonymous, forKey: "uid") + UserDefaults.standard.setValue(anonymous, forKey: Constants.uid) return anonymous } return id! @@ -22,13 +22,19 @@ public final class Engage: EngageProtocol { public func initialise(publicKey: String) -> Engage { - UserDefaults.standard.setValue(publicKey, forKey: "publicKey") + UserDefaults.standard.setValue(publicKey, forKey: Constants.publicKey) + NotificationService.shared.initialise() return .shared } public func identify(uid: String, properties: [String : Any]) { - UserDefaults.standard.setValue(uid, forKey: "uid") + let id = UserDefaults.standard.value(forKey: Constants.uid) as? String + if id != nil && id != uid { + merge(source: id!, destination: uid) + } + + UserDefaults.standard.setValue(uid, forKey: Constants.uid) var data: [String : Any] = [:] var meta: [String : Any] = [:] @@ -45,20 +51,28 @@ public final class Engage: EngageProtocol { data["meta"] = meta try? Network.shared.request(.identify(uid: uid, data: data.toData)) + guard UserDefaults.standard.value(forKey: Constants.hasUsageActivity) as? Bool ?? false else { + UserDefaults.standard.setValue(true, forKey: Constants.hasUsageActivity) + return + } } public func setDeviceToken(deviceToken: String, uid: String? = nil) { - UserDefaults.standard.setValue(deviceToken, forKey: "deviceToken") + UserDefaults.standard.setValue(deviceToken, forKey: Constants.deviceToken) let uid = userId(uid: uid) let data: [String : Any] = ["device_token": deviceToken, "device_platform": "ios", "app_version": Bundle.version, "app_build": Bundle.build, "app_last_active": Date()] try? Network.shared.request(.setDeviceToken(uid: uid, data: data.toData)) + guard UserDefaults.standard.value(forKey: Constants.hasUsageActivity) as? Bool ?? false else { + UserDefaults.standard.setValue(true, forKey: Constants.hasUsageActivity) + return + } } public func logout(deviceToken: String? = nil, uid: String? = nil) { let uid = userId(uid: uid) - let token = deviceToken ?? UserDefaults.standard.value(forKey: "deviceToken") as? String ?? "" + let token = deviceToken ?? UserDefaults.standard.value(forKey: Constants.deviceToken) as? String ?? "" try? Network.shared.request(.logout(uid: uid, deviceToken: token)) } @@ -122,5 +136,21 @@ public final class Engage: EngageProtocol { data["timestamp"] = date } try? Network.shared.request(.track(uid: uid, data: data.toData)) + guard UserDefaults.standard.value(forKey: Constants.hasUsageActivity) as? Bool ?? false else { + UserDefaults.standard.setValue(true, forKey: Constants.hasUsageActivity) + return + } + } + + public func onMessageOpened(_ handler: @escaping ([AnyHashable : Any]) -> Void) { + NotificationHandler.shared.setOnMessageOpened(handler) + } + + public func onMessageReceived(_ handler: @escaping ([AnyHashable : Any]) -> Void) { + NotificationHandler.shared.setOnMessageReceived(handler) + } + + public func showDialog(isCarousel: Bool) { + DialogHandler.shared.showDialog(isCarousel: isCarousel) } } diff --git a/Sources/Engage/EngageProtocol.swift b/Sources/Engage/EngageProtocol.swift index 9ef6f6d..9914b31 100644 --- a/Sources/Engage/EngageProtocol.swift +++ b/Sources/Engage/EngageProtocol.swift @@ -20,4 +20,7 @@ public protocol EngageProtocol { func convertToAccount(uid: String?) -> Void func merge(source: String, destination: String) -> Void func track(event: String, value: Any?, date: Date?, uid: String?) -> Void + func onMessageOpened(_ handler: @escaping ([AnyHashable : Any]) -> Void) -> Void + func onMessageReceived(_ handler: @escaping ([AnyHashable : Any]) -> Void) -> Void + func showDialog(isCarousel: Bool) -> Void } diff --git a/Sources/Handler/DialogHandler.swift b/Sources/Handler/DialogHandler.swift new file mode 100644 index 0000000..4f16923 --- /dev/null +++ b/Sources/Handler/DialogHandler.swift @@ -0,0 +1,170 @@ +// +// ViewHandler.swift +// +// +// Created by Ifeanyi Onuoha on 10/11/2024. +// + +import SwiftUI + +final class DialogHandler: DialogHandlerProtocol { + static let shared = DialogHandler() + + func showDialog(isCarousel: Bool) { + guard let keyWindow = UIApplication.shared.windows.first(where: { $0.isKeyWindow }) else { + return + } + + if let data = (isCarousel ? carouselMap : dialogMap).data(using: .utf8) { + do { + let inAppPayload: InAppPayload = try JSONMapper.decode(data) + + if isCarousel { + let hostingController = UIHostingController(rootView: CarouselDialogView( + inAppPayload: inAppPayload, + onDismissRequest: { + print("Carousel dismiss called") + keyWindow.rootViewController?.dismiss(animated: true) + } + )) + hostingController.modalPresentationStyle = .popover + + keyWindow.rootViewController?.present(hostingController, animated: true, completion: nil) + } else { + let hostingController = UIHostingController(rootView: SimpleDialogView( + inAppPayload: inAppPayload, + onDismissRequest: { + print("Dialog dismiss called") + keyWindow.rootViewController?.dismiss(animated: true) + } + )) + hostingController.modalPresentationStyle = .overCurrentContext + hostingController.modalTransitionStyle = .crossDissolve + hostingController.view.backgroundColor = UIColor.systemFill.withAlphaComponent(0.3) + + keyWindow.rootViewController?.present(hostingController, animated: true, completion: nil) + } + } catch { + print("Error converting data to model class.\n\(error.localizedDescription)\n\(error)") + } + } else { + print("Failed to convert JSON string to Data") + } + } +} + +let dialogMap = """ + { + "position": "center", + "background": "#FFFFFF", + "closeBtn": false, + "txtColor": "#000000", + "btnColor": "#007BFF", + "btnTxtColor": "#FFFFFF", + "borderRadius": 12, + "contents": [ + [ + { + "type": "text", + "content": "Dialog Title" + }, + { + "type": "image", + "url": "https://img.freepik.com/premium-photo/woman-holding-camera-with-hat-her-head-scarf-around-her-neck_1313501-26402.jpg", + "width": 100 + }, + { + "type": "text", + "content": "This is the dialog description. It provides more details to the user." + }, + { + "type": "button", + "content": "Continue", + "borderRadius": 8, + "buttonWidth": "50", + "action": "dismiss" + } + ] + ] + } +""" + +let carouselMap = """ + { + "position": "carousel", + "background": "#FFFFFF", + "closeBtn": false, + "txtColor": "#000000", + "btnColor": "#007BFF", + "btnTxtColor": "#FFFFFF", + "borderRadius": 12, + "contents": [ + [ + { + "type": "text", + "content": "Dialog One" + }, + { + "type": "image", + "url": "https://img.freepik.com/premium-photo/woman-holding-camera-with-hat-her-head-scarf-around-her-neck_1313501-26402.jpg", + "width": 100 + }, + { + "type": "text", + "content": "This is the dialog description. It provides more details to the user." + }, + { + "type": "button", + "content": "Continue", + "borderRadius": 8, + "buttonWidth": "100", + "action": "dismiss" + } + ], + [ + { + "type": "text", + "content": "Dialog Two" + }, + { + "type": "image", + "url": "https://img.freepik.com/premium-photo/woman-holding-camera-with-hat-her-head-scarf-around-her-neck_1313501-26402.jpg", + "width": 100 + }, + { + "type": "text", + "content": "This is the dialog description. It provides more details to the user." + }, + { + "type": "button", + "content": "Continue", + "borderRadius": 4, + "buttonWidth": "60", + "action": "dismiss" + } + ], + [ + { + "type": "text", + "content": "Dialog Three" + }, + { + "type": "image", + "url": "https://img.freepik.com/premium-photo/woman-holding-camera-with-hat-her-head-scarf-around-her-neck_1313501-26402.jpg", + "width": 100 + }, + { + "type": "text", + "content": "This is the dialog description. It provides more details to the user." + }, + { + "type": "button", + "content": "Done", + "borderRadius": 16, + "buttonWidth": "100", + "action": "dismiss" + } + ] + ] + } +""" diff --git a/Sources/Handler/DialogHandlerProtocol.swift b/Sources/Handler/DialogHandlerProtocol.swift new file mode 100644 index 0000000..a1b156f --- /dev/null +++ b/Sources/Handler/DialogHandlerProtocol.swift @@ -0,0 +1,12 @@ +// +// ViewHandlerProtocol.swift +// +// +// Created by Ifeanyi Onuoha on 10/11/2024. +// + +import Foundation + +protocol DialogHandlerProtocol { + func showDialog(isCarousel: Bool) -> Void +} diff --git a/Sources/Handler/NotificationHandler.swift b/Sources/Handler/NotificationHandler.swift new file mode 100644 index 0000000..42cb753 --- /dev/null +++ b/Sources/Handler/NotificationHandler.swift @@ -0,0 +1,43 @@ +// +// NotificationHandler.swift +// +// +// Created by Ifeanyi Onuoha on 16/09/2024. +// + +import Foundation + +final class NotificationHandler: NotificationHandlerProtocol { + static let shared = NotificationHandler() + + private var onMessageOpened: MessageHandler? + private var onMessageReceived: MessageHandler? + + func trackMessageOpened(userInfo: [AnyHashable : Any]) { + if let id = userInfo[Constants.messageId] as? String { + print("Identifier: \(id)") + let data: [String : Any] = ["event": "opened"] + + try? Network.shared.request(.trackNotification(id: id, data: data.toData)) + onMessageOpened?(userInfo) + } + } + + func trackMessageDelivered(userInfo: [AnyHashable : Any]) { + if let id = userInfo[Constants.messageId] as? String { + print("Identifier: \(id)") + let data: [String : Any] = ["event": "delivered"] + + try? Network.shared.request(.trackNotification(id: id, data: data.toData)) + onMessageReceived?(userInfo) + } + } + + func setOnMessageOpened(_ handler: @escaping ([AnyHashable : Any]) -> Void) { + onMessageOpened = handler + } + + func setOnMessageReceived(_ handler: @escaping ([AnyHashable : Any]) -> Void) { + onMessageReceived = handler + } +} diff --git a/Sources/Handler/NotificationHandlerProtocol.swift b/Sources/Handler/NotificationHandlerProtocol.swift new file mode 100644 index 0000000..f194b81 --- /dev/null +++ b/Sources/Handler/NotificationHandlerProtocol.swift @@ -0,0 +1,15 @@ +// +// NotificationHandlerProtocol.swift +// +// +// Created by Ifeanyi Onuoha on 16/09/2024. +// + +import Foundation + +protocol NotificationHandlerProtocol { + func trackMessageOpened(userInfo: [AnyHashable : Any]) -> Void + func trackMessageDelivered(userInfo: [AnyHashable : Any]) -> Void + func setOnMessageOpened(_ handler: @escaping ([AnyHashable : Any]) -> Void) -> Void + func setOnMessageReceived(_ handler: @escaping ([AnyHashable : Any]) -> Void) -> Void +} diff --git a/Sources/Models/InAppPayload.swift b/Sources/Models/InAppPayload.swift new file mode 100644 index 0000000..b98e460 --- /dev/null +++ b/Sources/Models/InAppPayload.swift @@ -0,0 +1,116 @@ +// +// InAppPayload.swift +// +// +// Created by Ifeanyi Onuoha on 10/11/2024. +// + +import SwiftUI + +enum ContentType: String, Codable { + case text, image, button, row +} + +struct InAppPayload: Codable { + let position: Position + let background: String + let closeBtn: Bool? + let txtColor: String + let btnColor: String + let btnTxtColor: String + let borderRadius: Int? // Inapplicable to carousel + let vAlign: Align? // Carousel only + let contents: [[Content]] + + var isCarousel: Bool { + position == .carousel + } + + var radius: CGFloat { + CGFloat(borderRadius ?? 16) + } + + var backgroundColor: Color { + Color.fromHex(hex: background) + } + + var textColor: Color { + Color.fromHex(hex: txtColor) + } + + var buttonColor: Color { + Color.fromHex(hex: btnColor) + } + + var buttonTextColor: Color { + Color.fromHex(hex: btnTxtColor) + } + + var alignment: Alignment { + switch position { + case .top: + Alignment.top + case .bottom: + Alignment.bottom + default: + Alignment.center + } + } +} + + +enum Position: String, Codable { + case top, bottom, center, carousel +} + +enum Align: String, Codable { + case between, end +} + +enum Action: String, Codable { + case dismiss, permission, intent, url, go +} + + +struct Content: Codable, Hashable { + let type: ContentType + + // Properties for Txt type + let content: String? + + // Properties for Image type + let url: String? + let width: Int? // Percentage; 100 default + + // Properties for Button type + let borderRadius: Int? + let buttonWidth: String? // "auto" or percentage as string + let action: Action? + let actionObj: String? + + // Properties for Row type + let align: Align? + let contents: [Content]? + + var butonRadius: CGFloat { + CGFloat(borderRadius ?? 8) + } + + var buttonFillWidth: Bool { + buttonWidth != "auto" + } + + var buttonMaxWidth: Double { + if buttonWidth != nil && Double(buttonWidth ?? "100") != nil { + return Double(buttonWidth ?? "100")! / 100 + } + return 100 + } + + var imageMaxWidth: Double { + if width != nil { + return Double(width!) / 100 + } + return 100 + } +} diff --git a/Sources/Network/Endpoint.swift b/Sources/Network/Endpoint.swift index a0b1419..fd54d7b 100644 --- a/Sources/Network/Endpoint.swift +++ b/Sources/Network/Endpoint.swift @@ -18,6 +18,7 @@ enum Endpoint { case convertToAccount(uid: String, data: Data?) case merge(data: Data?) case track(uid: String, data: Data?) + case trackNotification(id: String, data: Data?) } extension Endpoint { @@ -43,6 +44,8 @@ extension Endpoint { return "/v1/users/merge" case .track(let uid, _): return "/v1/users/\(uid)/events" + case .trackNotification(let id, _): + return "/v1/messages/mobile/push/\(id)/track" } } @@ -102,6 +105,8 @@ extension Endpoint { return .post(data: data) case .track(_, let data): return .post(data: data) + case .trackNotification(_, let data): + return .post(data: data) } } @@ -131,7 +136,7 @@ extension Endpoint { } } - let publicKey = UserDefaults.standard.string(forKey: "publicKey") ?? "" + let publicKey = UserDefaults.standard.string(forKey: Constants.publicKey) ?? "" let auth = "\(publicKey):".data(using: .utf8)?.base64EncodedString() ?? "" request.setValue("Basic \(auth)", forHTTPHeaderField: "Authorization") diff --git a/Sources/Notification/NotificationService.swift b/Sources/Notification/NotificationService.swift new file mode 100644 index 0000000..0934a90 --- /dev/null +++ b/Sources/Notification/NotificationService.swift @@ -0,0 +1,74 @@ +// +// NotificationService.swift +// +// +// Created by Ifeanyi Onuoha on 12/09/2024. +// + +import Foundation +import FirebaseMessaging +import UserNotifications +import UIKit + +typealias MessageHandler = ([AnyHashable : Any]) -> Void + +class NotificationService: NSObject, UNUserNotificationCenterDelegate, MessagingDelegate { + static let shared = NotificationService() + + func initialise() { + UNUserNotificationCenter.current().delegate = self + Messaging.messaging().delegate = self + } + + // Needed if swizzling is dissabled by setting FirebaseAppDelegateProxyEnabled to NO in the app’s Info.plist + func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + let tokenString = deviceToken.map { String(format: "%02.2hhx", $0) }.joined() + print("APNs token: \(tokenString)") + // Convert the device token to a string and set it in Firebase + Messaging.messaging().apnsToken = deviceToken + } + + // Called when APNs registration fails + func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { + print("Failed to register for remote notifications: \(error)") + } + + // Called when a notification is received while the app is in the foreground + func userNotificationCenter(_ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { + let userInfo = notification.request.content.userInfo + print("USER INFO FOR FOREGROUND \(userInfo)") + NotificationHandler.shared.trackMessageDelivered(userInfo: userInfo) + completionHandler([.alert, .sound]) + } + + // Called when a notification is received while the app is in the background (content-available: 1) + func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any], + fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { + print("USER INFO FOR BACKGROUND \(userInfo)") + NotificationHandler.shared.trackMessageDelivered(userInfo: userInfo) + completionHandler(.newData) + } + + // Called when a notification is opened (foreground, background, or terminated) + func userNotificationCenter(_ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void) { + let userInfo = response.notification.request.content.userInfo + print("USER INFO FOR CLICK ACTION \(userInfo)") + NotificationHandler.shared.trackMessageOpened(userInfo: userInfo) + completionHandler() + } + + func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) { + guard let deviceToken = fcmToken else { + return + } + let hasUsageActivity = UserDefaults.standard.value(forKey: Constants.hasUsageActivity) as? Bool ?? false + print("New FCM token: \(deviceToken), Has usage: \(hasUsageActivity)") + if (hasUsageActivity) { + Engage.shared.setDeviceToken(deviceToken: deviceToken) + } + } +} diff --git a/Sources/Util/Extension.swift b/Sources/Util/Extension.swift deleted file mode 100644 index 28470e2..0000000 --- a/Sources/Util/Extension.swift +++ /dev/null @@ -1,41 +0,0 @@ -// -// Extension.swift -// -// -// Created by Ifeanyi Onuoha on 29/03/2024. -// - -import Foundation - -let dateFormatter = ISO8601DateFormatter() - -func convertDatesToStrings(_ object: Any) -> Any { - if let date = object as? Date { - return dateFormatter.string(from: date) - } else if let dict = object as? [String: Any] { - return dict.mapValues { convertDatesToStrings($0) } - } else if let array = object as? [Any] { - return array.map { convertDatesToStrings($0) } - } - return object -} - -extension Dictionary { - var toData: Data? { - let jsonWithDatesAsString = convertDatesToStrings(self) - return try? JSONSerialization.data(withJSONObject: jsonWithDatesAsString) - } -} - -extension Bundle { - static var version: String { - let version = main.infoDictionary?["CFBundleShortVersionString"] as? String - guard version != nil else { return "" } - return "\(version!)" - } - static var build: String { - let build = main.infoDictionary?["CFBundleVersion"] as? String - guard build != nil else { return "" } - return "\(build!)" - } -} diff --git a/Sources/Utils/Constants.swift b/Sources/Utils/Constants.swift new file mode 100644 index 0000000..42436d5 --- /dev/null +++ b/Sources/Utils/Constants.swift @@ -0,0 +1,16 @@ +// +// Constants.swift +// +// +// Created by Ifeanyi Onuoha on 12/09/2024. +// + +import Foundation + +struct Constants { + static let uid = "uid" + static let publicKey = "publicKey" + static let deviceToken = "deviceToken" + static let messageId = "engage_msg_id" + static let hasUsageActivity = "hasUsageActivity" +} diff --git a/Sources/Utils/Extension.swift b/Sources/Utils/Extension.swift new file mode 100644 index 0000000..cc2400e --- /dev/null +++ b/Sources/Utils/Extension.swift @@ -0,0 +1,82 @@ +// +// Extension.swift +// +// +// Created by Ifeanyi Onuoha on 29/03/2024. +// + +import SwiftUI + +let dateFormatter = ISO8601DateFormatter() + +func convertDatesToStrings(_ object: Any) -> Any { + if let date = object as? Date { + return dateFormatter.string(from: date) + } else if let dict = object as? [String: Any] { + return dict.mapValues { convertDatesToStrings($0) } + } else if let array = object as? [Any] { + return array.map { convertDatesToStrings($0) } + } + return object +} + +extension Dictionary { + var toData: Data? { + let jsonWithDatesAsString = convertDatesToStrings(self) + return try? JSONSerialization.data(withJSONObject: jsonWithDatesAsString) + } +} + +extension Bundle { + static var version: String { + let version = main.infoDictionary?["CFBundleShortVersionString"] as? String + guard version != nil else { return "" } + return "\(version!)" + } + static var build: String { + let build = main.infoDictionary?["CFBundleVersion"] as? String + guard build != nil else { return "" } + return "\(build!)" + } +} + +extension UIColor { + convenience init?(hex: String) { + var hexSanitized = hex.trimmingCharacters(in: .whitespacesAndNewlines) + hexSanitized = hexSanitized.hasPrefix("#") ? String(hexSanitized.dropFirst()) : hexSanitized + + var rgb: UInt64 = 0 + Scanner(string: hexSanitized).scanHexInt64(&rgb) + + let red = CGFloat((rgb & 0xFF0000) >> 16) / 255.0 + let green = CGFloat((rgb & 0x00FF00) >> 8) / 255.0 + let blue = CGFloat(rgb & 0x0000FF) / 255.0 + let alpha: CGFloat = hexSanitized.count == 8 ? CGFloat((rgb & 0xFF000000) >> 24) / 255.0 : 1.0 + + self.init(red: red, green: green, blue: blue, alpha: alpha) + } +} + +extension Color { + static func fromHex(hex: String) -> Color { + guard let uiColor = UIColor(hex: hex) else { + return Color.init(red: 0, green: 0, blue: 0) + } + if #available(iOS 15.0, *) { + return Color(uiColor: uiColor) + } else { + return Color(uiColor) + } + } +} + +extension View { + @ViewBuilder + func `if`(_ condition: Bool, transform: (Self) -> Content) -> some View { + if condition { + transform(self) + } else { + self + } + } +} diff --git a/Sources/Utils/JSONMapper.swift b/Sources/Utils/JSONMapper.swift new file mode 100644 index 0000000..ee52c7d --- /dev/null +++ b/Sources/Utils/JSONMapper.swift @@ -0,0 +1,28 @@ +// +// JSONMapper.swift +// +// +// Created by Ifeanyi Onuoha on 10/11/2024. +// + +import SwiftUI + +struct JSONMapper { + static func decode(_ data: Data) throws -> T { + // 1. Create a decoder + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .useDefaultKeys + + // 2. Create a property for the decoded data + return try decoder.decode(T.self, from: data) + } + + static func encode(_ data: T) throws -> Data { + // 1. Create an encoder + let encoder = JSONEncoder() + encoder.keyEncodingStrategy = .useDefaultKeys + + // 2. Create a property for the encoded data + return try encoder.encode(data) + } +} diff --git a/Sources/Views/CarouselDialogView.swift b/Sources/Views/CarouselDialogView.swift new file mode 100644 index 0000000..509eeb1 --- /dev/null +++ b/Sources/Views/CarouselDialogView.swift @@ -0,0 +1,177 @@ +// +// SwiftUIView.swift +// +// +// Created by Ifeanyi Onuoha on 10/11/2024. +// + +import SwiftUI + +struct CarouselDialogView: View { + var inAppPayload: InAppPayload + var onDismissRequest: () -> Void + @State private var currentPage = 0 + + var body: some View { + VStack { + PageViewController( + currentPage: $currentPage, + pages: inAppPayload.contents.map({ contents in + ScrollView { + ForEach(contents, id: \.self) { content in + switch content.type { + case .row: + Text("Row") + case .image: + GeometryReader { geometry in + NetworkImageView( + imageUrl: content.url ?? "", + radius: inAppPayload.radius + ) + .frame(width: geometry.size.width * content.imageMaxWidth) + } + .aspectRatio(16/9, contentMode: .fit) + case .text: + Text(content.content ?? "") + .multilineTextAlignment(.center) + default: + EmptyView() + } + } + } + .padding() + }) + ) + + Spacer(minLength: 20) + + HStack { + ForEach(0...inAppPayload.contents.count - 1, id: \.self) { i in + Circle() + .frame(width: 8, height: 8) + .foregroundColor(i == currentPage ? inAppPayload.buttonColor : Color.gray) + + } + } + + Spacer(minLength: 20) + + ForEach(inAppPayload.contents[currentPage], id: \.self) { content in + switch content.type { + case .button: + GeometryReader { geometry in + Button(content.content ?? "") { + if currentPage < inAppPayload.contents.count - 1 { + currentPage += 1 + } else { + onDismissRequest() + } + } + .padding() + .if(content.buttonFillWidth) { view in + view.frame(width: geometry.size.width * content.buttonMaxWidth) + } + .background(inAppPayload.buttonColor) + .foregroundColor(inAppPayload.buttonTextColor) + .cornerRadius(content.butonRadius) + .frame(width: geometry.size.width, alignment: .center) + + } + .frame(height: 50) + .padding(.horizontal) + + default: + EmptyView() + } + } + } + } +} + +#Preview { + if let data = carouselMap.data(using: .utf8) { + do { + let inAppPayload: InAppPayload = try JSONMapper.decode(data) + + return CarouselDialogView( + inAppPayload: inAppPayload, + onDismissRequest: { + print("Carousel dismiss called") + } + ) + } catch { + print("Error converting data to model class.\n\(error.localizedDescription)\n\(error)") + return Rectangle() + } + } else { + print("Failed to convert JSON string to Data") + return Rectangle() + } +} + +struct PageViewController: UIViewControllerRepresentable { + @Binding var currentPage: Int + var pages: [Page] + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + func makeUIViewController(context: Context) -> UIPageViewController { + let pageViewController = UIPageViewController( + transitionStyle: .scroll, + navigationOrientation: .horizontal + ) + pageViewController.dataSource = context.coordinator + pageViewController.delegate = context.coordinator + + return pageViewController + } + + func updateUIViewController(_ pageViewController: UIPageViewController, context: Context) { + pageViewController.setViewControllers( + [context.coordinator.controllers[currentPage]], + direction: .forward, + animated: true + ) + } + + class Coordinator: NSObject, UIPageViewControllerDataSource, UIPageViewControllerDelegate { + var parent: PageViewController + var controllers: [UIViewController] + + init(_ pageViewController: PageViewController) { + parent = pageViewController + controllers = parent.pages.map { UIHostingController(rootView: $0) } + } + + func pageViewController( + _ pageViewController: UIPageViewController, + viewControllerBefore viewController: UIViewController + ) -> UIViewController? { + guard let index = controllers.firstIndex(of: viewController) else { return nil } + return index == 0 ? nil : controllers[index - 1] + } + + func pageViewController( + _ pageViewController: UIPageViewController, + viewControllerAfter viewController: UIViewController + ) -> UIViewController? { + guard let index = controllers.firstIndex(of: viewController) else { return nil } + return index + 1 == controllers.count ? nil : controllers[index + 1] + } + + func pageViewController( + _ pageViewController: UIPageViewController, + didFinishAnimating finished: Bool, + previousViewControllers: [UIViewController], + transitionCompleted completed: Bool + ) { + if completed, + let visibleViewController = pageViewController.viewControllers?.first, + let index = controllers.firstIndex(of: visibleViewController) { + parent.currentPage = index + } + } + } +} diff --git a/Sources/Views/NetworkImageView.swift b/Sources/Views/NetworkImageView.swift new file mode 100644 index 0000000..bbe0526 --- /dev/null +++ b/Sources/Views/NetworkImageView.swift @@ -0,0 +1,53 @@ +// +// NetworkImageView.swift +// +// +// Created by Ifeanyi Onuoha on 16/11/2024. +// + +import SwiftUI + +struct NetworkImageView: View { + let imageUrl: String + let radius: CGFloat + + @State private var uiImage: UIImage? = nil + @State private var isLoading = true + + var body: some View { + ZStack { + if let uiImage = uiImage { + Image(uiImage: uiImage) + .resizable() + .scaledToFit() + .cornerRadius(radius) + } else if isLoading { + Rectangle() + .cornerRadius(radius) + } else { + Image(systemName: "exclamationmark.triangle") + .foregroundColor(.red) + } + } + .onAppear { + loadImage() + } + } + + private func loadImage() { + guard let url = URL(string: imageUrl) else { return } + + URLSession.shared.dataTask(with: url) { data, response, error in + if let data = data, let downloadedImage = UIImage(data: data) { + DispatchQueue.main.async { + self.uiImage = downloadedImage + self.isLoading = false + } + } else { + DispatchQueue.main.async { + self.isLoading = false + } + } + }.resume() + } +} diff --git a/Sources/Views/SimpleDialogView.swift b/Sources/Views/SimpleDialogView.swift new file mode 100644 index 0000000..0b23e44 --- /dev/null +++ b/Sources/Views/SimpleDialogView.swift @@ -0,0 +1,96 @@ +// +// SimpleDialogView.swift +// +// +// Created by Ifeanyi Onuoha on 10/11/2024. +// + +import SwiftUI + +struct SimpleDialogView: View { + var inAppPayload: InAppPayload + var onDismissRequest: () -> Void + + var body: some View { + GeometryReader { geometry in + VStack { + VStack { + if inAppPayload.closeBtn ?? false { + HStack { + Spacer() + Button(action: onDismissRequest, label: { + Image(systemName: "xmark") + .resizable() + .frame(width: 12, height: 12) + .foregroundColor(inAppPayload.buttonColor) + }) + } + + } + ForEach(inAppPayload.contents.first ?? [], id: \.self) { content in + switch content.type { + case .row: + Text("Row") + case .image: + GeometryReader { geometry in + NetworkImageView( + imageUrl: content.url ?? "", + radius: inAppPayload.radius + ) + .frame(width: geometry.size.width * content.imageMaxWidth) + } + .aspectRatio(16/9, contentMode: .fit) + case .text: + Text(content.content ?? "") + .multilineTextAlignment(.center) + case .button: + GeometryReader { geometry in + Button(content.content ?? "") { + onDismissRequest() + } + .padding() + .if(content.buttonFillWidth) { view in + view.frame(width: geometry.size.width * content.buttonMaxWidth) + } + .background(inAppPayload.buttonColor) + .foregroundColor(inAppPayload.buttonTextColor) + .cornerRadius(content.butonRadius) + .frame(width: geometry.size.width, alignment: .center) + } + .frame(height: 50) + } + } + } + .padding() + .frame(width: .infinity) + .background(inAppPayload.backgroundColor) + .cornerRadius(inAppPayload.radius) + .padding() + + } + .frame(height: geometry.size.height, alignment: inAppPayload.alignment) + } + } +} + +#Preview { + if let data = dialogMap.data(using: .utf8) { + do { + let inAppPayload: InAppPayload = try JSONMapper.decode(data) + + return SimpleDialogView( + inAppPayload: inAppPayload, + onDismissRequest: { + print("Dialog dismiss called") + } + ) + } catch { + print("Error converting data to model class.\n\(error.localizedDescription)\n\(error)") + return Rectangle() + } + } else { + print("Failed to convert JSON string to Data") + return Rectangle() + } +} +