@loyalytics/swan-react-native-sdk 2.1.3-beta.0 → 2.1.3-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/android/build.gradle +66 -0
- package/android/src/main/AndroidManifest.xml +10 -0
- package/android/src/main/kotlin/com/loyalytics/swan/SwanNotificationModule.kt +43 -0
- package/android/src/main/kotlin/com/loyalytics/swan/SwanNotificationPackage.kt +16 -0
- package/android/src/main/kotlin/com/loyalytics/swan/templates/SwanNotificationActionReceiver.kt +49 -0
- package/android/src/main/kotlin/com/loyalytics/swan/templates/SwanNotificationTemplate.kt +20 -0
- package/android/src/main/kotlin/com/loyalytics/swan/templates/SwanTemplateRegistry.kt +47 -0
- package/android/src/main/kotlin/com/loyalytics/swan/templates/carousel/CarouselAutoRemoteViews.kt +103 -0
- package/android/src/main/kotlin/com/loyalytics/swan/templates/carousel/CarouselFilmstripRemoteViews.kt +132 -0
- package/android/src/main/kotlin/com/loyalytics/swan/templates/carousel/CarouselRemoteViews.kt +129 -0
- package/android/src/main/kotlin/com/loyalytics/swan/templates/carousel/CarouselTemplate.kt +412 -0
- package/android/src/main/kotlin/com/loyalytics/swan/templates/common/NotificationBitmapCache.kt +70 -0
- package/android/src/main/kotlin/com/loyalytics/swan/templates/common/NotificationImageLoader.kt +97 -0
- package/android/src/main/kotlin/com/loyalytics/swan/templates/common/NotificationStateManager.kt +85 -0
- package/android/src/main/res/anim/swan_fade_in.xml +6 -0
- package/android/src/main/res/anim/swan_fade_out.xml +6 -0
- package/android/src/main/res/anim/swan_slide_in_right.xml +8 -0
- package/android/src/main/res/anim/swan_slide_out_left.xml +8 -0
- package/android/src/main/res/drawable/swan_ic_chevron_left.xml +11 -0
- package/android/src/main/res/drawable/swan_ic_chevron_right.xml +11 -0
- package/android/src/main/res/layout/swan_carousel_auto_expanded.xml +51 -0
- package/android/src/main/res/layout/swan_carousel_collapsed.xml +31 -0
- package/android/src/main/res/layout/swan_carousel_expanded.xml +96 -0
- package/android/src/main/res/layout/swan_carousel_filmstrip_expanded.xml +115 -0
- package/android/src/main/res/layout/swan_carousel_flipper_item.xml +7 -0
- package/android/src/test/kotlin/com/loyalytics/swan/templates/carousel/CarouselTemplateTest.kt +125 -0
- package/docs/SDK_INDUSTRY_REVIEW_REPORT.md +347 -0
- package/docs/Swan_Push_Notifications.postman_collection.json +330 -0
- package/docs/deep-link-attribution.md +281 -0
- package/ios/SwanNotificationContentExtension/Info.plist +40 -0
- package/ios/SwanNotificationContentExtension/MainInterface.storyboard +19 -0
- package/ios/SwanNotificationContentExtension/NotificationViewController.swift +190 -0
- package/ios/SwanNotificationContentExtension/SwanNotificationContentExtension.entitlements +10 -0
- package/ios/SwanNotificationContentExtension/common/ImageDownloader.swift +32 -0
- package/ios/SwanNotificationContentExtension/templates/CarouselView.swift +336 -0
- package/lib/commonjs/constants/ApiUrls.js.map +1 -1
- package/lib/commonjs/index.js +150 -35
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/providers/NullPushProvider.js.map +1 -1
- package/lib/commonjs/services/DeviceRegistrationService.js.map +1 -1
- package/lib/commonjs/state/AuthStateMachine.js.map +1 -1
- package/lib/commonjs/state/DeviceStateMachine.js.map +1 -1
- package/lib/commonjs/state/PushStateMachine.js.map +1 -1
- package/lib/commonjs/utils/FirebaseNotificationManager.js.map +1 -1
- package/lib/commonjs/utils/Logger.js.map +1 -1
- package/lib/commonjs/utils/SharedCredentialsManager.js +28 -0
- package/lib/commonjs/utils/SharedCredentialsManager.js.map +1 -1
- package/lib/commonjs/version.js +1 -1
- package/lib/module/index.js +150 -35
- package/lib/module/index.js.map +1 -1
- package/lib/module/providers/NullPushProvider.js.map +1 -1
- package/lib/module/services/DeviceRegistrationService.js.map +1 -1
- package/lib/module/state/AuthStateMachine.js.map +1 -1
- package/lib/module/state/DeviceStateMachine.js.map +1 -1
- package/lib/module/state/PushStateMachine.js.map +1 -1
- package/lib/module/utils/FirebaseNotificationManager.js.map +1 -1
- package/lib/module/utils/Logger.js.map +1 -1
- package/lib/module/utils/SharedCredentialsManager.js +28 -0
- package/lib/module/utils/SharedCredentialsManager.js.map +1 -1
- package/lib/module/version.js +1 -1
- package/lib/typescript/commonjs/src/constants/ApiUrls.d.ts.map +1 -1
- package/lib/typescript/commonjs/src/index.d.ts +24 -7
- package/lib/typescript/commonjs/src/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/src/providers/NullPushProvider.d.ts.map +1 -1
- package/lib/typescript/commonjs/src/services/DeviceRegistrationService.d.ts.map +1 -1
- package/lib/typescript/commonjs/src/state/AuthStateMachine.d.ts.map +1 -1
- package/lib/typescript/commonjs/src/state/DeviceStateMachine.d.ts.map +1 -1
- package/lib/typescript/commonjs/src/state/PushStateMachine.d.ts.map +1 -1
- package/lib/typescript/commonjs/src/utils/FirebaseNotificationManager.d.ts.map +1 -1
- package/lib/typescript/commonjs/src/utils/Logger.d.ts.map +1 -1
- package/lib/typescript/commonjs/src/utils/SharedCredentialsManager.d.ts +13 -0
- package/lib/typescript/commonjs/src/utils/SharedCredentialsManager.d.ts.map +1 -1
- package/lib/typescript/commonjs/src/version.d.ts +1 -1
- package/lib/typescript/module/src/constants/ApiUrls.d.ts.map +1 -1
- package/lib/typescript/module/src/index.d.ts +24 -7
- package/lib/typescript/module/src/index.d.ts.map +1 -1
- package/lib/typescript/module/src/providers/NullPushProvider.d.ts.map +1 -1
- package/lib/typescript/module/src/services/DeviceRegistrationService.d.ts.map +1 -1
- package/lib/typescript/module/src/state/AuthStateMachine.d.ts.map +1 -1
- package/lib/typescript/module/src/state/DeviceStateMachine.d.ts.map +1 -1
- package/lib/typescript/module/src/state/PushStateMachine.d.ts.map +1 -1
- package/lib/typescript/module/src/utils/FirebaseNotificationManager.d.ts.map +1 -1
- package/lib/typescript/module/src/utils/Logger.d.ts.map +1 -1
- package/lib/typescript/module/src/utils/SharedCredentialsManager.d.ts +13 -0
- package/lib/typescript/module/src/utils/SharedCredentialsManager.d.ts.map +1 -1
- package/lib/typescript/module/src/version.d.ts +1 -1
- package/package.json +7 -10
- package/react-native.config.json +12 -0
- package/scripts/setup-ios-extension.js +100 -20
- package/scripts/test-carousel-push.js +266 -0
- package/swan-react-native-sdk.podspec +18 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="21701" targetRuntime="AppleSDK" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="jFH-bh-RYg">
|
|
3
|
+
<device id="retina6_12" orientation="portrait" appearance="light"/>
|
|
4
|
+
<scenes>
|
|
5
|
+
<scene sceneID="8ME-jv-LvP">
|
|
6
|
+
<objects>
|
|
7
|
+
<viewController id="jFH-bh-RYg" customClass="NotificationViewController" customModule="SwanNotificationContentExtension" customModuleProvider="target" sceneMemberID="viewController">
|
|
8
|
+
<view key="view" contentMode="scaleToFill" id="2Vz-2H-oeq">
|
|
9
|
+
<rect key="frame" x="0.0" y="0.0" width="393" height="300"/>
|
|
10
|
+
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
|
11
|
+
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
|
|
12
|
+
</view>
|
|
13
|
+
</viewController>
|
|
14
|
+
<placeholder placeholderIdentifier="IBFirstResponder" id="B3F-wd-TrL" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
|
|
15
|
+
</objects>
|
|
16
|
+
<point key="canvasLocation" x="132" y="132"/>
|
|
17
|
+
</scene>
|
|
18
|
+
</scenes>
|
|
19
|
+
</document>
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import UIKit
|
|
2
|
+
import UserNotifications
|
|
3
|
+
import UserNotificationsUI
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Notification Content Extension entry point.
|
|
7
|
+
* Routes to the correct template view based on notification category.
|
|
8
|
+
*
|
|
9
|
+
* Supported categories:
|
|
10
|
+
* - swan_carousel: Swipeable product carousel
|
|
11
|
+
* (Future: swan_timer, swan_cta, swan_rating)
|
|
12
|
+
*
|
|
13
|
+
* Action buttons (registered by SDK via Notifee setNotificationCategories):
|
|
14
|
+
* - swan_prev: Navigate to previous carousel item
|
|
15
|
+
* - swan_next: Navigate to next carousel item
|
|
16
|
+
* - swan_open: Open deep link for current item
|
|
17
|
+
*/
|
|
18
|
+
class NotificationViewController: UIViewController, UNNotificationContentExtension {
|
|
19
|
+
|
|
20
|
+
private var carouselView: CarouselView?
|
|
21
|
+
|
|
22
|
+
/// Action button identifiers (must match what SDK registers via setNotificationCategories)
|
|
23
|
+
private enum ActionID {
|
|
24
|
+
static let prev = "swan_prev"
|
|
25
|
+
static let next = "swan_next"
|
|
26
|
+
static let open = "swan_open"
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
required init?(coder: NSCoder) {
|
|
30
|
+
self.carouselView = nil
|
|
31
|
+
super.init(coder: coder)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) {
|
|
35
|
+
self.carouselView = nil
|
|
36
|
+
super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
override func viewDidLoad() {
|
|
40
|
+
super.viewDidLoad()
|
|
41
|
+
view.backgroundColor = .systemBackground
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
func didReceive(_ notification: UNNotification) {
|
|
45
|
+
let content = notification.request.content
|
|
46
|
+
let category = content.categoryIdentifier
|
|
47
|
+
let userInfo = content.userInfo
|
|
48
|
+
|
|
49
|
+
switch category {
|
|
50
|
+
case "swan_carousel":
|
|
51
|
+
setupCarousel(userInfo: userInfo)
|
|
52
|
+
default:
|
|
53
|
+
// Unknown category — show basic content
|
|
54
|
+
setupFallback(content: content)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
func didReceive(
|
|
59
|
+
_ response: UNNotificationResponse,
|
|
60
|
+
completionHandler completion: @escaping (UNNotificationContentExtensionResponseOption) -> Void
|
|
61
|
+
) {
|
|
62
|
+
let userInfo = response.notification.request.content.userInfo
|
|
63
|
+
let category = response.notification.request.content.categoryIdentifier
|
|
64
|
+
let actionId = response.actionIdentifier
|
|
65
|
+
|
|
66
|
+
guard category == "swan_carousel" else {
|
|
67
|
+
completion(.dismissAndForwardAction)
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
switch actionId {
|
|
72
|
+
case ActionID.prev:
|
|
73
|
+
carouselView?.scrollToPrevious()
|
|
74
|
+
completion(.doNotDismiss)
|
|
75
|
+
|
|
76
|
+
case ActionID.next:
|
|
77
|
+
carouselView?.scrollToNext()
|
|
78
|
+
completion(.doNotDismiss)
|
|
79
|
+
|
|
80
|
+
case ActionID.open:
|
|
81
|
+
handleCarouselAction(response: response, userInfo: userInfo)
|
|
82
|
+
completion(.dismissAndForwardAction)
|
|
83
|
+
|
|
84
|
+
case UNNotificationDefaultActionIdentifier:
|
|
85
|
+
// User tapped the notification body itself
|
|
86
|
+
handleCarouselAction(response: response, userInfo: userInfo)
|
|
87
|
+
completion(.dismissAndForwardAction)
|
|
88
|
+
|
|
89
|
+
default:
|
|
90
|
+
completion(.dismissAndForwardAction)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// MARK: - Carousel
|
|
95
|
+
|
|
96
|
+
private func setupCarousel(userInfo: [AnyHashable: Any]) {
|
|
97
|
+
let carousel = CarouselView()
|
|
98
|
+
carousel.translatesAutoresizingMaskIntoConstraints = false
|
|
99
|
+
view.addSubview(carousel)
|
|
100
|
+
|
|
101
|
+
NSLayoutConstraint.activate([
|
|
102
|
+
carousel.topAnchor.constraint(equalTo: view.topAnchor),
|
|
103
|
+
carousel.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
|
104
|
+
carousel.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
|
105
|
+
carousel.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
|
106
|
+
])
|
|
107
|
+
|
|
108
|
+
// Parse carousel items
|
|
109
|
+
let itemsString = userInfo["carouselItems"] as? String ?? "[]"
|
|
110
|
+
let modeString = userInfo["carouselMode"] as? String ?? "manual"
|
|
111
|
+
let intervalString = userInfo["carouselInterval"] as? String ?? "3000"
|
|
112
|
+
let interval = TimeInterval(intervalString) ?? 3000
|
|
113
|
+
|
|
114
|
+
let items = CarouselView.parseItems(from: itemsString)
|
|
115
|
+
let mode: CarouselView.CarouselMode = modeString == "auto" ? .auto : .manual
|
|
116
|
+
carousel.configure(with: items, mode: mode, autoScrollInterval: interval / 1000.0)
|
|
117
|
+
|
|
118
|
+
// Tap on carousel image/labels saves click data and opens the app
|
|
119
|
+
carousel.onItemTapped = { [weak self] in
|
|
120
|
+
guard let self = self, let cv = self.carouselView else { return }
|
|
121
|
+
let messageId = userInfo["messageId"] as? String ?? ""
|
|
122
|
+
self.saveClickData(
|
|
123
|
+
messageId: messageId,
|
|
124
|
+
itemIndex: cv.currentIndex,
|
|
125
|
+
route: cv.currentItem?.route ?? ""
|
|
126
|
+
)
|
|
127
|
+
self.extensionContext?.performNotificationDefaultAction()
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
self.carouselView = carousel
|
|
131
|
+
|
|
132
|
+
// Set preferred content size for expanded notification
|
|
133
|
+
let imageAspectRatio: CGFloat = 0.6 // height / width
|
|
134
|
+
let width = view.bounds.width > 0 ? view.bounds.width : UIScreen.main.bounds.width
|
|
135
|
+
preferredContentSize = CGSize(width: width, height: width * imageAspectRatio + 80)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private func handleCarouselAction(response: UNNotificationResponse, userInfo: [AnyHashable: Any]) {
|
|
139
|
+
guard let carousel = carouselView else { return }
|
|
140
|
+
|
|
141
|
+
let messageId = userInfo["messageId"] as? String ?? ""
|
|
142
|
+
let currentItem = carousel.currentItem
|
|
143
|
+
|
|
144
|
+
// Save click data to App Group for main app to read
|
|
145
|
+
saveClickData(
|
|
146
|
+
messageId: messageId,
|
|
147
|
+
itemIndex: carousel.currentIndex,
|
|
148
|
+
route: currentItem?.route ?? ""
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// MARK: - Click Data Persistence
|
|
153
|
+
|
|
154
|
+
private func saveClickData(messageId: String, itemIndex: Int, route: String) {
|
|
155
|
+
let appGroupId = "group.swan.sdk.notifications"
|
|
156
|
+
guard let defaults = UserDefaults(suiteName: appGroupId) else { return }
|
|
157
|
+
|
|
158
|
+
let clickData: [String: Any] = [
|
|
159
|
+
"messageId": messageId,
|
|
160
|
+
"itemIndex": itemIndex,
|
|
161
|
+
"route": route,
|
|
162
|
+
"timestamp": Date().timeIntervalSince1970,
|
|
163
|
+
]
|
|
164
|
+
|
|
165
|
+
// Serialize as JSON string — react-native-shared-group-preferences only reads strings
|
|
166
|
+
if let jsonData = try? JSONSerialization.data(withJSONObject: clickData),
|
|
167
|
+
let jsonString = String(data: jsonData, encoding: .utf8) {
|
|
168
|
+
defaults.set(jsonString, forKey: "swanTemplateClickData")
|
|
169
|
+
defaults.synchronize()
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// MARK: - Fallback
|
|
174
|
+
|
|
175
|
+
private func setupFallback(content: UNNotificationContent) {
|
|
176
|
+
let label = UILabel()
|
|
177
|
+
label.text = content.body
|
|
178
|
+
label.numberOfLines = 0
|
|
179
|
+
label.textAlignment = .center
|
|
180
|
+
label.translatesAutoresizingMaskIntoConstraints = false
|
|
181
|
+
view.addSubview(label)
|
|
182
|
+
|
|
183
|
+
NSLayoutConstraint.activate([
|
|
184
|
+
label.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
|
185
|
+
label.centerYAnchor.constraint(equalTo: view.centerYAnchor),
|
|
186
|
+
label.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
|
|
187
|
+
label.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
|
|
188
|
+
])
|
|
189
|
+
}
|
|
190
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3
|
+
<plist version="1.0">
|
|
4
|
+
<dict>
|
|
5
|
+
<key>com.apple.security.application-groups</key>
|
|
6
|
+
<array>
|
|
7
|
+
<string>group.swan.sdk.notifications</string>
|
|
8
|
+
</array>
|
|
9
|
+
</dict>
|
|
10
|
+
</plist>
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import UIKit
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shared image downloader for notification template views.
|
|
5
|
+
* Uses URLSession for background-safe downloading.
|
|
6
|
+
*/
|
|
7
|
+
class ImageDownloader {
|
|
8
|
+
|
|
9
|
+
static func download(urlString: String, completion: @escaping (UIImage?) -> Void) {
|
|
10
|
+
guard let url = URL(string: urlString) else {
|
|
11
|
+
completion(nil)
|
|
12
|
+
return
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
let config = URLSessionConfiguration.default
|
|
16
|
+
config.timeoutIntervalForRequest = 10
|
|
17
|
+
config.timeoutIntervalForResource = 15
|
|
18
|
+
let session = URLSession(configuration: config)
|
|
19
|
+
|
|
20
|
+
session.dataTask(with: url) { data, response, error in
|
|
21
|
+
guard error == nil,
|
|
22
|
+
let data = data,
|
|
23
|
+
let httpResponse = response as? HTTPURLResponse,
|
|
24
|
+
httpResponse.statusCode == 200,
|
|
25
|
+
let image = UIImage(data: data) else {
|
|
26
|
+
completion(nil)
|
|
27
|
+
return
|
|
28
|
+
}
|
|
29
|
+
completion(image)
|
|
30
|
+
}.resume()
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import UIKit
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Carousel view for notification content extension.
|
|
5
|
+
* Displays a horizontal, swipeable collection of product images with titles.
|
|
6
|
+
*
|
|
7
|
+
* Supports:
|
|
8
|
+
* - Manual mode: user swipes to navigate
|
|
9
|
+
* - Auto mode: auto-scrolls at interval, pauses on touch
|
|
10
|
+
*/
|
|
11
|
+
class CarouselView: UIView {
|
|
12
|
+
|
|
13
|
+
// MARK: - Types
|
|
14
|
+
|
|
15
|
+
enum CarouselMode {
|
|
16
|
+
case manual
|
|
17
|
+
case auto
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
struct CarouselItem {
|
|
21
|
+
let imageUrl: String
|
|
22
|
+
let title: String?
|
|
23
|
+
let body: String?
|
|
24
|
+
let route: String?
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// MARK: - Properties
|
|
28
|
+
|
|
29
|
+
private(set) var currentIndex: Int = 0
|
|
30
|
+
private var items: [CarouselItem] = []
|
|
31
|
+
private var mode: CarouselMode = .manual
|
|
32
|
+
private(set) var autoScrollTimer: Timer?
|
|
33
|
+
private var autoScrollInterval: TimeInterval = 3.0
|
|
34
|
+
|
|
35
|
+
var currentItem: CarouselItem? {
|
|
36
|
+
guard currentIndex >= 0 && currentIndex < items.count else { return nil }
|
|
37
|
+
return items[currentIndex]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// MARK: - UI Elements
|
|
41
|
+
|
|
42
|
+
private lazy var collectionView: UICollectionView = {
|
|
43
|
+
let layout = UICollectionViewFlowLayout()
|
|
44
|
+
layout.scrollDirection = .horizontal
|
|
45
|
+
layout.minimumLineSpacing = 0
|
|
46
|
+
layout.minimumInteritemSpacing = 0
|
|
47
|
+
let cv = UICollectionView(frame: .zero, collectionViewLayout: layout)
|
|
48
|
+
cv.isPagingEnabled = true
|
|
49
|
+
cv.showsHorizontalScrollIndicator = false
|
|
50
|
+
cv.backgroundColor = .clear
|
|
51
|
+
cv.delegate = self
|
|
52
|
+
cv.dataSource = self
|
|
53
|
+
cv.register(CarouselImageCell.self, forCellWithReuseIdentifier: CarouselImageCell.reuseId)
|
|
54
|
+
return cv
|
|
55
|
+
}()
|
|
56
|
+
|
|
57
|
+
private lazy var pageControl: UIPageControl = {
|
|
58
|
+
let pc = UIPageControl()
|
|
59
|
+
pc.currentPageIndicatorTintColor = .label
|
|
60
|
+
pc.pageIndicatorTintColor = .systemGray3
|
|
61
|
+
pc.isUserInteractionEnabled = false
|
|
62
|
+
pc.accessibilityLabel = "Carousel page indicator"
|
|
63
|
+
return pc
|
|
64
|
+
}()
|
|
65
|
+
|
|
66
|
+
private lazy var titleLabel: UILabel = {
|
|
67
|
+
let label = UILabel()
|
|
68
|
+
label.font = .systemFont(ofSize: 14, weight: .semibold)
|
|
69
|
+
label.textColor = .label
|
|
70
|
+
label.numberOfLines = 1
|
|
71
|
+
return label
|
|
72
|
+
}()
|
|
73
|
+
|
|
74
|
+
private lazy var bodyLabel: UILabel = {
|
|
75
|
+
let label = UILabel()
|
|
76
|
+
label.font = .systemFont(ofSize: 12)
|
|
77
|
+
label.textColor = .secondaryLabel
|
|
78
|
+
label.numberOfLines = 1
|
|
79
|
+
return label
|
|
80
|
+
}()
|
|
81
|
+
|
|
82
|
+
/// Called when user taps a carousel item (image or label area)
|
|
83
|
+
var onItemTapped: (() -> Void)?
|
|
84
|
+
|
|
85
|
+
// Image cache
|
|
86
|
+
private var imageCache: [Int: UIImage] = [:]
|
|
87
|
+
|
|
88
|
+
// MARK: - Init
|
|
89
|
+
|
|
90
|
+
override init(frame: CGRect) {
|
|
91
|
+
super.init(frame: frame)
|
|
92
|
+
setupUI()
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
required init?(coder: NSCoder) {
|
|
96
|
+
super.init(coder: coder)
|
|
97
|
+
setupUI()
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
deinit {
|
|
101
|
+
stopAutoScroll()
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// MARK: - Setup
|
|
105
|
+
|
|
106
|
+
private func setupUI() {
|
|
107
|
+
[collectionView, titleLabel, bodyLabel, pageControl].forEach {
|
|
108
|
+
$0.translatesAutoresizingMaskIntoConstraints = false
|
|
109
|
+
addSubview($0)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Tap anywhere on carousel opens the app
|
|
113
|
+
let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap))
|
|
114
|
+
self.addGestureRecognizer(tap)
|
|
115
|
+
|
|
116
|
+
NSLayoutConstraint.activate([
|
|
117
|
+
collectionView.topAnchor.constraint(equalTo: topAnchor),
|
|
118
|
+
collectionView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
|
119
|
+
collectionView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
|
120
|
+
collectionView.heightAnchor.constraint(equalTo: widthAnchor, multiplier: 0.6),
|
|
121
|
+
|
|
122
|
+
titleLabel.topAnchor.constraint(equalTo: collectionView.bottomAnchor, constant: 8),
|
|
123
|
+
titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 12),
|
|
124
|
+
titleLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12),
|
|
125
|
+
|
|
126
|
+
bodyLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 2),
|
|
127
|
+
bodyLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 12),
|
|
128
|
+
bodyLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12),
|
|
129
|
+
|
|
130
|
+
pageControl.topAnchor.constraint(equalTo: bodyLabel.bottomAnchor, constant: 4),
|
|
131
|
+
pageControl.centerXAnchor.constraint(equalTo: centerXAnchor),
|
|
132
|
+
pageControl.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4),
|
|
133
|
+
])
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// MARK: - Public API
|
|
137
|
+
|
|
138
|
+
func configure(with items: [CarouselItem], mode: CarouselMode, autoScrollInterval: TimeInterval = 3.0) {
|
|
139
|
+
self.items = items
|
|
140
|
+
self.mode = mode
|
|
141
|
+
self.autoScrollInterval = autoScrollInterval
|
|
142
|
+
self.currentIndex = 0
|
|
143
|
+
|
|
144
|
+
pageControl.numberOfPages = items.count
|
|
145
|
+
pageControl.currentPage = 0
|
|
146
|
+
pageControl.isHidden = items.count <= 1
|
|
147
|
+
|
|
148
|
+
collectionView.reloadData()
|
|
149
|
+
updateLabels()
|
|
150
|
+
|
|
151
|
+
// Pre-download images
|
|
152
|
+
for (index, item) in items.enumerated() {
|
|
153
|
+
ImageDownloader.download(urlString: item.imageUrl) { [weak self] image in
|
|
154
|
+
DispatchQueue.main.async {
|
|
155
|
+
self?.imageCache[index] = image
|
|
156
|
+
if let cell = self?.collectionView.cellForItem(at: IndexPath(item: index, section: 0)) as? CarouselImageCell {
|
|
157
|
+
cell.imageView.image = image
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if mode == .auto && items.count > 1 {
|
|
164
|
+
startAutoScroll()
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
func scrollToNext() {
|
|
169
|
+
guard items.count > 1 else { return }
|
|
170
|
+
let nextIndex = (currentIndex + 1) % items.count
|
|
171
|
+
scrollToIndex(nextIndex)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
func scrollToPrevious() {
|
|
175
|
+
guard items.count > 1 else { return }
|
|
176
|
+
let prevIndex = (currentIndex - 1 + items.count) % items.count
|
|
177
|
+
scrollToIndex(prevIndex)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
func scrollToIndex(_ index: Int) {
|
|
181
|
+
guard index >= 0 && index < items.count else { return }
|
|
182
|
+
currentIndex = index
|
|
183
|
+
collectionView.scrollToItem(
|
|
184
|
+
at: IndexPath(item: index, section: 0),
|
|
185
|
+
at: .centeredHorizontally,
|
|
186
|
+
animated: true
|
|
187
|
+
)
|
|
188
|
+
pageControl.currentPage = index
|
|
189
|
+
updateLabels()
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// MARK: - Auto Scroll
|
|
193
|
+
|
|
194
|
+
private func startAutoScroll() {
|
|
195
|
+
stopAutoScroll()
|
|
196
|
+
autoScrollTimer = Timer.scheduledTimer(withTimeInterval: autoScrollInterval, repeats: true) { [weak self] _ in
|
|
197
|
+
self?.scrollToNext()
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
func stopAutoScroll() {
|
|
202
|
+
autoScrollTimer?.invalidate()
|
|
203
|
+
autoScrollTimer = nil
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
@objc private func handleTap() {
|
|
207
|
+
onItemTapped?()
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// MARK: - Helpers
|
|
211
|
+
|
|
212
|
+
private func updateLabels() {
|
|
213
|
+
guard currentIndex < items.count else { return }
|
|
214
|
+
let item = items[currentIndex]
|
|
215
|
+
titleLabel.text = item.title
|
|
216
|
+
bodyLabel.text = item.body
|
|
217
|
+
bodyLabel.isHidden = (item.body ?? "").isEmpty
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// MARK: - Parsing
|
|
221
|
+
|
|
222
|
+
static func parseItems(from jsonString: String) -> [CarouselItem] {
|
|
223
|
+
guard let data = jsonString.data(using: .utf8),
|
|
224
|
+
let jsonArray = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else {
|
|
225
|
+
return []
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return jsonArray.compactMap { dict in
|
|
229
|
+
guard let imageUrl = dict["imageUrl"] as? String, !imageUrl.isEmpty else {
|
|
230
|
+
return nil
|
|
231
|
+
}
|
|
232
|
+
return CarouselItem(
|
|
233
|
+
imageUrl: imageUrl,
|
|
234
|
+
title: dict["title"] as? String,
|
|
235
|
+
body: dict["body"] as? String,
|
|
236
|
+
route: dict["route"] as? String
|
|
237
|
+
)
|
|
238
|
+
}.prefix(10).map { $0 }
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// MARK: - UICollectionViewDataSource & Delegate
|
|
243
|
+
|
|
244
|
+
extension CarouselView: UICollectionViewDataSource, UICollectionViewDelegate, UICollectionViewDelegateFlowLayout {
|
|
245
|
+
|
|
246
|
+
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
|
|
247
|
+
return items.count
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
|
|
251
|
+
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: CarouselImageCell.reuseId, for: indexPath) as! CarouselImageCell
|
|
252
|
+
let item = items[indexPath.item]
|
|
253
|
+
|
|
254
|
+
// Accessibility
|
|
255
|
+
cell.isAccessibilityElement = true
|
|
256
|
+
cell.accessibilityLabel = item.title ?? "Carousel image \(indexPath.item + 1) of \(items.count)"
|
|
257
|
+
if let body = item.body, !body.isEmpty {
|
|
258
|
+
cell.accessibilityValue = body
|
|
259
|
+
}
|
|
260
|
+
cell.accessibilityTraits = .image
|
|
261
|
+
|
|
262
|
+
if let cached = imageCache[indexPath.item] {
|
|
263
|
+
cell.imageView.image = cached
|
|
264
|
+
} else {
|
|
265
|
+
cell.imageView.image = nil
|
|
266
|
+
// Download if not cached
|
|
267
|
+
ImageDownloader.download(urlString: item.imageUrl) { [weak self, weak cell] image in
|
|
268
|
+
DispatchQueue.main.async {
|
|
269
|
+
self?.imageCache[indexPath.item] = image
|
|
270
|
+
cell?.imageView.image = image
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return cell
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
|
|
278
|
+
return collectionView.bounds.size
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
|
|
282
|
+
let page = Int(scrollView.contentOffset.x / scrollView.bounds.width)
|
|
283
|
+
if page != currentIndex {
|
|
284
|
+
currentIndex = page
|
|
285
|
+
pageControl.currentPage = page
|
|
286
|
+
updateLabels()
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Always restart auto-scroll after any drag settles, even if index unchanged
|
|
290
|
+
if mode == .auto && items.count > 1 {
|
|
291
|
+
startAutoScroll()
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
|
|
296
|
+
// Pause auto-scroll during manual interaction
|
|
297
|
+
if mode == .auto {
|
|
298
|
+
stopAutoScroll()
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// MARK: - CarouselImageCell
|
|
304
|
+
|
|
305
|
+
class CarouselImageCell: UICollectionViewCell {
|
|
306
|
+
static let reuseId = "CarouselImageCell"
|
|
307
|
+
|
|
308
|
+
let imageView: UIImageView = {
|
|
309
|
+
let iv = UIImageView()
|
|
310
|
+
iv.contentMode = .scaleAspectFill
|
|
311
|
+
iv.clipsToBounds = true
|
|
312
|
+
iv.backgroundColor = .systemGray5
|
|
313
|
+
return iv
|
|
314
|
+
}()
|
|
315
|
+
|
|
316
|
+
override init(frame: CGRect) {
|
|
317
|
+
super.init(frame: frame)
|
|
318
|
+
imageView.translatesAutoresizingMaskIntoConstraints = false
|
|
319
|
+
contentView.addSubview(imageView)
|
|
320
|
+
NSLayoutConstraint.activate([
|
|
321
|
+
imageView.topAnchor.constraint(equalTo: contentView.topAnchor),
|
|
322
|
+
imageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
|
|
323
|
+
imageView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
|
|
324
|
+
imageView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
|
|
325
|
+
])
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
required init?(coder: NSCoder) {
|
|
329
|
+
fatalError("init(coder:) has not been implemented")
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
override func prepareForReuse() {
|
|
333
|
+
super.prepareForReuse()
|
|
334
|
+
imageView.image = nil
|
|
335
|
+
}
|
|
336
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"names":["WEBHOOK_BASE_URL","STAGE","PROD","WEB_SDK_BASE_URL","ECOM_SDK_BASE_URL","ECOM_ENRICH_PROFILE_URL","exports","ECOM_TRACK_EVENT_URL","ECOM_DEVICE_REGISTER_URL","ECOM_PUSH_SUBSCRIPTION_URL","WEBHOOK_MOBILE_PUSH_URL","NOTIFICATION_GET_URL","_default","default"],"sourceRoot":"../../../src","sources":["constants/ApiUrls.ts"],"mappings":";;;;;;AAAA;AACA;AACA;AACA;;AAEA,MAAMA,gBAAgB,GAAG;EACvBC,KAAK,EAAE,iCAAiC;EACxCC,IAAI,EAAE;AACR,CAAC;AAED,MAAMC,gBAAgB,GAAG;EACvBF,KAAK,EAAE,wCAAwC;EAC/CC,IAAI,EAAE;AACR,CAAC;AAED,MAAME,iBAAiB,GAAG;EACxBH,KAAK,EAAE,+BAA+B;EACtCC,IAAI,EAAE;AACR,CAAC;AAEM,MAAMG,uBAAuB,GAAAC,OAAA,CAAAD,uBAAA,GAAG;EACrCJ,KAAK,EAAE,GAAGG,iBAAiB,CAACH,KAAK,6BAA6B;EAC9DC,IAAI,EAAE,GAAGE,iBAAiB,CAACF,IAAI;AACjC,CAAC;AAEM,MAAMK,oBAAoB,GAAAD,OAAA,CAAAC,oBAAA,GAAG;EAClCN,KAAK,EAAE,GAAGG,iBAAiB,CAACH,KAAK,gBAAgB;EACjDC,IAAI,EAAE,GAAGE,iBAAiB,CAACF,IAAI;AACjC,CAAC;AAEM,MAAMM,wBAAwB,GAAAF,OAAA,CAAAE,wBAAA,GAAG;EACtCP,KAAK,EAAE,GAAGG,iBAAiB,CAACH,KAAK,qBAAqB;EACtDC,IAAI,EAAE,GAAGE,iBAAiB,CAACF,IAAI;AACjC,CAAC;AAEM,MAAMO,0BAA0B,GAAAH,OAAA,CAAAG,0BAAA,GAAG;EACxCR,KAAK,EAAE,GAAGG,iBAAiB,CAACH,KAAK,2BAA2B;EAC5DC,IAAI,EAAE,GAAGE,iBAAiB,CAACF,IAAI;AACjC,CAAC;AAEM,MAAMQ,uBAAuB,GAAAJ,OAAA,CAAAI,uBAAA,GAAG;EACrCT,KAAK,EAAE,GAAGD,gBAAgB,CAACC,KAAK,uBAAuB;EACvDC,IAAI,EAAE,GAAGF,gBAAgB,CAACE,IAAI;AAChC,CAAC;AAEM,MAAMS,oBAAoB,GAAAL,OAAA,CAAAK,oBAAA,GAAG;EAClCV,KAAK,EAAE,GAAGE,gBAAgB,CAACF,KAAK,kCAAkC;EAClEC,IAAI,EAAE,GAAGC,gBAAgB,CAACD,IAAI;AAChC,CAAC;
|
|
1
|
+
{"version":3,"names":["WEBHOOK_BASE_URL","STAGE","PROD","WEB_SDK_BASE_URL","ECOM_SDK_BASE_URL","ECOM_ENRICH_PROFILE_URL","exports","ECOM_TRACK_EVENT_URL","ECOM_DEVICE_REGISTER_URL","ECOM_PUSH_SUBSCRIPTION_URL","WEBHOOK_MOBILE_PUSH_URL","NOTIFICATION_GET_URL","_default","default"],"sourceRoot":"../../../src","sources":["constants/ApiUrls.ts"],"mappings":";;;;;;AAAA;AACA;AACA;AACA;;AAEA,MAAMA,gBAAgB,GAAG;EACvBC,KAAK,EAAE,iCAAiC;EACxCC,IAAI,EAAE;AACR,CAAC;AAED,MAAMC,gBAAgB,GAAG;EACvBF,KAAK,EAAE,wCAAwC;EAC/CC,IAAI,EAAE;AACR,CAAC;AAED,MAAME,iBAAiB,GAAG;EACxBH,KAAK,EAAE,+BAA+B;EACtCC,IAAI,EAAE;AACR,CAAC;AAEM,MAAMG,uBAAuB,GAAAC,OAAA,CAAAD,uBAAA,GAAG;EACrCJ,KAAK,EAAE,GAAGG,iBAAiB,CAACH,KAAK,6BAA6B;EAC9DC,IAAI,EAAE,GAAGE,iBAAiB,CAACF,IAAI;AACjC,CAAC;AAEM,MAAMK,oBAAoB,GAAAD,OAAA,CAAAC,oBAAA,GAAG;EAClCN,KAAK,EAAE,GAAGG,iBAAiB,CAACH,KAAK,gBAAgB;EACjDC,IAAI,EAAE,GAAGE,iBAAiB,CAACF,IAAI;AACjC,CAAC;AAEM,MAAMM,wBAAwB,GAAAF,OAAA,CAAAE,wBAAA,GAAG;EACtCP,KAAK,EAAE,GAAGG,iBAAiB,CAACH,KAAK,qBAAqB;EACtDC,IAAI,EAAE,GAAGE,iBAAiB,CAACF,IAAI;AACjC,CAAC;AAEM,MAAMO,0BAA0B,GAAAH,OAAA,CAAAG,0BAAA,GAAG;EACxCR,KAAK,EAAE,GAAGG,iBAAiB,CAACH,KAAK,2BAA2B;EAC5DC,IAAI,EAAE,GAAGE,iBAAiB,CAACF,IAAI;AACjC,CAAC;AAEM,MAAMQ,uBAAuB,GAAAJ,OAAA,CAAAI,uBAAA,GAAG;EACrCT,KAAK,EAAE,GAAGD,gBAAgB,CAACC,KAAK,uBAAuB;EACvDC,IAAI,EAAE,GAAGF,gBAAgB,CAACE,IAAI;AAChC,CAAC;AAEM,MAAMS,oBAAoB,GAAAL,OAAA,CAAAK,oBAAA,GAAG;EAClCV,KAAK,EAAE,GAAGE,gBAAgB,CAACF,KAAK,kCAAkC;EAClEC,IAAI,EAAE,GAAGC,gBAAgB,CAACD,IAAI;AAChC,CAAC;AAAC,IAAAU,QAAA,GAAAN,OAAA,CAAAO,OAAA,GAEa;EACbR,uBAAuB;EACvBE,oBAAoB;EACpBC,wBAAwB;EACxBC,0BAA0B;EAC1BC,uBAAuB;EACvBC;AACF,CAAC","ignoreList":[]}
|