@bubblsdk/react-native-sdk 4.1.7 → 5.0.0-alpha.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/BubblReactNativeSdk.podspec +29 -9
- package/CHANGELOG.md +52 -1
- package/LICENSE +202 -1
- package/README.md +172 -83
- package/android/build.gradle +33 -30
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/tech/bubbl/reactnative/BubblJson.kt +135 -0
- package/android/src/main/java/tech/bubbl/reactnative/BubblSdkModule.kt +277 -0
- package/android/src/main/java/tech/bubbl/reactnative/BubblSdkPackage.kt +26 -0
- package/app.plugin.js +2 -0
- package/ios/BubblPush.swift +44 -0
- package/ios/BubblSdk.h +7 -0
- package/ios/BubblSdk.mm +252 -0
- package/ios/BubblSdkImpl.swift +335 -0
- package/ios-extension/BubblReactNativeNotificationService.podspec +44 -0
- package/ios-extension/Sources/BubblCore/API/ApiResponse.swift +90 -0
- package/ios-extension/Sources/BubblCore/API/CredentialStore.swift +116 -0
- package/ios-extension/Sources/BubblCore/API/DeviceApiClient.swift +239 -0
- package/ios-extension/Sources/BubblCore/API/ErrorActions.swift +68 -0
- package/ios-extension/Sources/BubblCore/API/Http.swift +39 -0
- package/ios-extension/Sources/BubblCore/API/ServerClock.swift +69 -0
- package/ios-extension/Sources/BubblCore/BubblVersion.swift +5 -0
- package/ios-extension/Sources/BubblCore/Device/ConfigSync.swift +216 -0
- package/ios-extension/Sources/BubblCore/Device/DeviceSync.swift +160 -0
- package/ios-extension/Sources/BubblCore/Device/ProvisioningProfile.swift +24 -0
- package/ios-extension/Sources/BubblCore/Engine/EngineCore.swift +523 -0
- package/ios-extension/Sources/BubblCore/Engine/Geofencing.swift +170 -0
- package/ios-extension/Sources/BubblCore/Engine/Permissions.swift +90 -0
- package/ios-extension/Sources/BubblCore/Engine/Privacy.swift +64 -0
- package/ios-extension/Sources/BubblCore/Engine/PrivacyControls.swift +184 -0
- package/ios-extension/Sources/BubblCore/Engine/Pushes.swift +61 -0
- package/ios-extension/Sources/BubblCore/Engine/Sandbox.swift +149 -0
- package/ios-extension/Sources/BubblCore/Events/EventQueue.swift +207 -0
- package/ios-extension/Sources/BubblCore/Events/EventStore.swift +159 -0
- package/ios-extension/Sources/BubblCore/Geofence/Geofence.swift +160 -0
- package/ios-extension/Sources/BubblCore/Geofence/GeofenceEngine.swift +488 -0
- package/ios-extension/Sources/BubblCore/Geofence/GeofenceState.swift +103 -0
- package/ios-extension/Sources/BubblCore/Geofence/RegionBudget.swift +62 -0
- package/ios-extension/Sources/BubblCore/Notification/BubblNotification.swift +137 -0
- package/ios-extension/Sources/BubblCore/Notification/NotificationEvents.swift +160 -0
- package/ios-extension/Sources/BubblCore/Notification/Push.swift +134 -0
- package/ios-extension/Sources/BubblCore/Notification/SurveyForm.swift +185 -0
- package/ios-extension/Sources/BubblCore/RequestSigner.swift +56 -0
- package/ios-extension/Sources/BubblCore/Support/AsyncMutex.swift +39 -0
- package/ios-extension/Sources/BubblCore/Support/BubblLog.swift +52 -0
- package/ios-extension/Sources/BubblCore/Support/JSON.swift +31 -0
- package/ios-extension/Sources/BubblCore/Support/JSONValue.swift +94 -0
- package/ios-extension/Sources/BubblCore/Support/Locking.swift +11 -0
- package/ios-extension/Sources/BubblCore/Support/ValueStore.swift +109 -0
- package/ios-extension/Sources/BubblNotificationService/BubblNotificationService.swift +124 -0
- package/ios-sdk/BubblCore/API/ApiResponse.swift +90 -0
- package/ios-sdk/BubblCore/API/CredentialStore.swift +116 -0
- package/ios-sdk/BubblCore/API/DeviceApiClient.swift +239 -0
- package/ios-sdk/BubblCore/API/ErrorActions.swift +68 -0
- package/ios-sdk/BubblCore/API/Http.swift +39 -0
- package/ios-sdk/BubblCore/API/ServerClock.swift +69 -0
- package/ios-sdk/BubblCore/BubblVersion.swift +5 -0
- package/ios-sdk/BubblCore/Device/ConfigSync.swift +216 -0
- package/ios-sdk/BubblCore/Device/DeviceSync.swift +160 -0
- package/ios-sdk/BubblCore/Device/ProvisioningProfile.swift +24 -0
- package/ios-sdk/BubblCore/Engine/EngineCore.swift +523 -0
- package/ios-sdk/BubblCore/Engine/Geofencing.swift +170 -0
- package/ios-sdk/BubblCore/Engine/Permissions.swift +90 -0
- package/ios-sdk/BubblCore/Engine/Privacy.swift +64 -0
- package/ios-sdk/BubblCore/Engine/PrivacyControls.swift +184 -0
- package/ios-sdk/BubblCore/Engine/Pushes.swift +61 -0
- package/ios-sdk/BubblCore/Engine/Sandbox.swift +149 -0
- package/ios-sdk/BubblCore/Events/EventQueue.swift +207 -0
- package/ios-sdk/BubblCore/Events/EventStore.swift +159 -0
- package/ios-sdk/BubblCore/Geofence/Geofence.swift +160 -0
- package/ios-sdk/BubblCore/Geofence/GeofenceEngine.swift +488 -0
- package/ios-sdk/BubblCore/Geofence/GeofenceState.swift +103 -0
- package/ios-sdk/BubblCore/Geofence/RegionBudget.swift +62 -0
- package/ios-sdk/BubblCore/Notification/BubblNotification.swift +137 -0
- package/ios-sdk/BubblCore/Notification/NotificationEvents.swift +160 -0
- package/ios-sdk/BubblCore/Notification/Push.swift +134 -0
- package/ios-sdk/BubblCore/Notification/SurveyForm.swift +185 -0
- package/ios-sdk/BubblCore/RequestSigner.swift +56 -0
- package/ios-sdk/BubblCore/Support/AsyncMutex.swift +39 -0
- package/ios-sdk/BubblCore/Support/BubblLog.swift +52 -0
- package/ios-sdk/BubblCore/Support/JSON.swift +31 -0
- package/ios-sdk/BubblCore/Support/JSONValue.swift +94 -0
- package/ios-sdk/BubblCore/Support/Locking.swift +11 -0
- package/ios-sdk/BubblCore/Support/ValueStore.swift +109 -0
- package/ios-sdk/BubblLaunch/BubblLaunch.m +45 -0
- package/ios-sdk/BubblLaunch/include/BubblLaunch.h +5 -0
- package/ios-sdk/BubblSDK/Bubbl.swift +308 -0
- package/ios-sdk/BubblSDK/BubblBackend.swift +118 -0
- package/ios-sdk/BubblSDK/BubblCredential.swift +30 -0
- package/ios-sdk/BubblSDK/BubblMessage.swift +151 -0
- package/ios-sdk/BubblSDK/BubblOptions.swift +39 -0
- package/ios-sdk/BubblSDK/BubblPermissions.swift +79 -0
- package/ios-sdk/BubblSDK/BubblTestDeviceResult.swift +10 -0
- package/ios-sdk/BubblSDK/Engine/BackgroundRefresh.swift +92 -0
- package/ios-sdk/BubblSDK/Engine/EngineHost+Geofences.swift +177 -0
- package/ios-sdk/BubblSDK/Engine/EngineHost+Privacy.swift +65 -0
- package/ios-sdk/BubblSDK/Engine/EngineHost+Push.swift +154 -0
- package/ios-sdk/BubblSDK/Engine/EngineHost.swift +356 -0
- package/ios-sdk/BubblSDK/Engine/SupportedBubbl.swift +320 -0
- package/ios-sdk/BubblSDK/Engine/SystemLog.swift +27 -0
- package/ios-sdk/BubblSDK/Engine/WorkRunner.swift +107 -0
- package/ios-sdk/BubblSDK/Notification/Look.swift +78 -0
- package/ios-sdk/BubblSDK/Notification/NotificationMedia.swift +329 -0
- package/ios-sdk/BubblSDK/Notification/NotificationModel.swift +172 -0
- package/ios-sdk/BubblSDK/Notification/NotificationScreen.swift +428 -0
- package/ios-sdk/BubblSDK/Notification/ScreenPresenter.swift +148 -0
- package/ios-sdk/BubblSDK/Permissions/PermissionFlow.swift +169 -0
- package/ios-sdk/BubblSDK/Permissions/PrivacyView.swift +110 -0
- package/ios-sdk/BubblSDK/Platform/AppleDevicePlatform.swift +95 -0
- package/ios-sdk/BubblSDK/Platform/KeychainCredentialStore.swift +114 -0
- package/ios-sdk/BubblSDK/Platform/LocationService.swift +418 -0
- package/ios-sdk/BubblSDK/Platform/PushIntegration.swift +288 -0
- package/ios-sdk/BubblSDK/Platform/URLSessionHttpClient.swift +69 -0
- package/ios-sdk/BubblSDK/PrivacyInfo.xcprivacy +76 -0
- package/jest/index.js +75 -0
- package/lib/commonjs/BubblModule.js +14 -0
- package/lib/commonjs/BubblModule.js.map +1 -0
- package/lib/commonjs/BubblModule.web.js +13 -0
- package/lib/commonjs/BubblModule.web.js.map +1 -0
- package/lib/commonjs/NativeBubblSdk.js +20 -0
- package/lib/commonjs/NativeBubblSdk.js.map +1 -0
- package/lib/commonjs/index.js +336 -0
- package/lib/commonjs/index.js.map +1 -0
- package/lib/commonjs/package.json +1 -0
- package/lib/commonjs/types.js +2 -0
- package/lib/commonjs/types.js.map +1 -0
- package/lib/module/BubblModule.js +5 -0
- package/lib/module/BubblModule.js.map +1 -0
- package/lib/module/BubblModule.web.js +9 -0
- package/lib/module/BubblModule.web.js.map +1 -0
- package/lib/module/NativeBubblSdk.js +18 -0
- package/lib/module/NativeBubblSdk.js.map +1 -0
- package/lib/module/index.js +331 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/types.js +2 -0
- package/lib/module/types.js.map +1 -0
- package/lib/typescript/commonjs/package.json +1 -0
- package/lib/typescript/commonjs/src/BubblModule.d.ts +3 -0
- package/lib/typescript/commonjs/src/BubblModule.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/BubblModule.web.d.ts +8 -0
- package/lib/typescript/commonjs/src/BubblModule.web.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/NativeBubblSdk.d.ts +59 -0
- package/lib/typescript/commonjs/src/NativeBubblSdk.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/index.d.ts +111 -0
- package/lib/typescript/commonjs/src/index.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/types.d.ts +192 -0
- package/lib/typescript/commonjs/src/types.d.ts.map +1 -0
- package/lib/typescript/module/package.json +1 -0
- package/lib/typescript/module/src/BubblModule.d.ts +3 -0
- package/lib/typescript/module/src/BubblModule.d.ts.map +1 -0
- package/lib/typescript/module/src/BubblModule.web.d.ts +8 -0
- package/lib/typescript/module/src/BubblModule.web.d.ts.map +1 -0
- package/lib/typescript/module/src/NativeBubblSdk.d.ts +59 -0
- package/lib/typescript/module/src/NativeBubblSdk.d.ts.map +1 -0
- package/lib/typescript/module/src/index.d.ts +111 -0
- package/lib/typescript/module/src/index.d.ts.map +1 -0
- package/lib/typescript/module/src/types.d.ts +192 -0
- package/lib/typescript/module/src/types.d.ts.map +1 -0
- package/package.json +105 -24
- package/plugin/build/imageNotifications.d.ts +35 -0
- package/plugin/build/imageNotifications.js +198 -0
- package/plugin/build/index.d.ts +84 -0
- package/plugin/build/index.js +176 -0
- package/src/BubblModule.ts +2 -0
- package/src/BubblModule.web.ts +9 -0
- package/src/NativeBubblSdk.ts +67 -0
- package/src/index.ts +338 -282
- package/src/types.ts +182 -0
- package/android/src/main/kotlin/tech/bubbl/reactnative/BubblSdkModule.kt +0 -680
- package/android/src/main/kotlin/tech/bubbl/reactnative/BubblSdkNotificationIntents.kt +0 -32
- package/android/src/main/kotlin/tech/bubbl/reactnative/BubblSdkPackage.kt +0 -14
- package/ios/BubblSdk.swift +0 -802
- package/ios/BubblSdkBridge.m +0 -86
- package/react-native.config.js +0 -11
- package/src/specs/NativeBubblSdk.ts +0 -54
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
#if os(iOS)
|
|
2
|
+
import SwiftUI
|
|
3
|
+
import UIKit
|
|
4
|
+
#if !COCOAPODS
|
|
5
|
+
import BubblCore
|
|
6
|
+
#endif
|
|
7
|
+
|
|
8
|
+
/// One notification on Bubbl's screen: what the person has done with it, the survey's answers and
|
|
9
|
+
/// step, and the events that go with it (as Android's NotificationActivity): displayed (or opened,
|
|
10
|
+
/// from a tapped notification) once it's up, survey started and submitted, CTA clicks, media
|
|
11
|
+
/// viewed and completed, and dismissed when it's closed any other way.
|
|
12
|
+
@available(iOS 17, *)
|
|
13
|
+
@MainActor
|
|
14
|
+
final class NotificationModel: ObservableObject {
|
|
15
|
+
let notification: BubblNotification
|
|
16
|
+
let opened: Bool
|
|
17
|
+
|
|
18
|
+
@Published var form: SurveyForm
|
|
19
|
+
/// What's typed in each open-ended or number question, as typed (the form keeps it tidied).
|
|
20
|
+
@Published var typed: [String: String] = [:]
|
|
21
|
+
@Published private(set) var step = 0
|
|
22
|
+
@Published private(set) var showingError = false
|
|
23
|
+
@Published private(set) var submitted = false
|
|
24
|
+
|
|
25
|
+
/// Closed by answering, a CTA or a media link: anything else counts as dismissing it.
|
|
26
|
+
private var acted = false
|
|
27
|
+
private var surveyStarted = false
|
|
28
|
+
private var appeared = false
|
|
29
|
+
private let events: NotificationEvents?
|
|
30
|
+
private let close: @MainActor () -> Void
|
|
31
|
+
|
|
32
|
+
init(notification: BubblNotification, opened: Bool, events: NotificationEvents?, close: @escaping @MainActor () -> Void) {
|
|
33
|
+
self.notification = notification
|
|
34
|
+
self.opened = opened
|
|
35
|
+
self.events = events
|
|
36
|
+
self.close = close
|
|
37
|
+
form = SurveyForm(notification.questions)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
var questions: [BubblNotification.Question] { form.questions }
|
|
41
|
+
var hasSurvey: Bool { notification.isSurvey && !questions.isEmpty }
|
|
42
|
+
var isLastStep: Bool { step == questions.count - 1 }
|
|
43
|
+
|
|
44
|
+
// MARK: - The screen
|
|
45
|
+
|
|
46
|
+
func appear() {
|
|
47
|
+
guard !appeared else { return }
|
|
48
|
+
appeared = true
|
|
49
|
+
let notification = notification
|
|
50
|
+
if opened {
|
|
51
|
+
record { try await $0.opened(notification) }
|
|
52
|
+
} else {
|
|
53
|
+
record { try await $0.displayed(notification) }
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/// The close button.
|
|
58
|
+
func dismiss() {
|
|
59
|
+
if !acted {
|
|
60
|
+
let notification = notification
|
|
61
|
+
record { try await $0.dismissed(notification) }
|
|
62
|
+
}
|
|
63
|
+
close()
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
func openCta() {
|
|
67
|
+
guard let cta = notification.cta else { return }
|
|
68
|
+
acted = true
|
|
69
|
+
let notification = notification
|
|
70
|
+
record { try await $0.ctaClicked(notification) }
|
|
71
|
+
open(cta.url)
|
|
72
|
+
close()
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// MARK: - Media
|
|
76
|
+
|
|
77
|
+
/// An image shown, or a video, YouTube video or audio started.
|
|
78
|
+
func mediaViewed() {
|
|
79
|
+
let notification = notification
|
|
80
|
+
record { try await $0.mediaViewed(notification, positionSeconds: 0) }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
func mediaCompleted(seconds: Double) {
|
|
84
|
+
let notification = notification
|
|
85
|
+
record { try await $0.mediaCompleted(notification, positionSeconds: seconds.isFinite ? seconds : 0) }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/// Media of a kind the card can't play (a PDF, a file): opened outside.
|
|
89
|
+
func openMedia(_ url: String) {
|
|
90
|
+
acted = true
|
|
91
|
+
mediaViewed()
|
|
92
|
+
open(url)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// MARK: - The survey, one question at a time
|
|
96
|
+
|
|
97
|
+
/// Any answer given: the first means the survey has been started.
|
|
98
|
+
func answered() {
|
|
99
|
+
if !surveyStarted {
|
|
100
|
+
surveyStarted = true
|
|
101
|
+
let notification = notification
|
|
102
|
+
record { try await $0.surveyStarted(notification) }
|
|
103
|
+
}
|
|
104
|
+
if let question = questions[safe: step], form.answer(question.id) != nil { showingError = false }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
func next() {
|
|
108
|
+
guard let question = questions[safe: step] else { return }
|
|
109
|
+
if question.required && form.answer(question.id) == nil {
|
|
110
|
+
showingError = true
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
if isLastStep { submit() } else { go(to: step + 1) }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
func back() {
|
|
117
|
+
go(to: step - 1)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private func go(to index: Int) {
|
|
121
|
+
step = min(max(index, 0), max(questions.count - 1, 0))
|
|
122
|
+
showingError = false
|
|
123
|
+
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private func submit() {
|
|
127
|
+
// Each step checked its own question; this catches a required one skipped by Back.
|
|
128
|
+
if let missing = form.missing.first, let index = questions.firstIndex(of: missing) {
|
|
129
|
+
go(to: index)
|
|
130
|
+
showingError = true
|
|
131
|
+
return
|
|
132
|
+
}
|
|
133
|
+
acted = true
|
|
134
|
+
let notification = notification
|
|
135
|
+
let answers = form
|
|
136
|
+
record { try await $0.surveySubmitted(notification, answers) }
|
|
137
|
+
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
|
|
138
|
+
submitted = true
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/// Done, from the thank-you step.
|
|
142
|
+
func finish() {
|
|
143
|
+
close()
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// MARK: - Helpers
|
|
147
|
+
|
|
148
|
+
private func open(_ link: String) {
|
|
149
|
+
guard LinkPolicy.canOpen(link), let url = URL(string: link) else { return }
|
|
150
|
+
UIApplication.shared.open(url)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/// Records an event on the engine's side, so closing the screen doesn't lose it, then sends
|
|
154
|
+
/// the queue.
|
|
155
|
+
private func record(_ body: @escaping @Sendable (NotificationEvents) async throws -> Void) {
|
|
156
|
+
guard let events else { return }
|
|
157
|
+
Task {
|
|
158
|
+
do {
|
|
159
|
+
try await body(events)
|
|
160
|
+
EngineHost.shared.submit("events") { await $0.flushEvents() }
|
|
161
|
+
} catch {
|
|
162
|
+
BubblLog.debug("A notification event wasn't recorded (\(type(of: error)))")
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
@available(iOS 17, *)
|
|
169
|
+
extension Array {
|
|
170
|
+
subscript(safe index: Int) -> Element? { indices.contains(index) ? self[index] : nil }
|
|
171
|
+
}
|
|
172
|
+
#endif
|
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
#if os(iOS)
|
|
2
|
+
import SwiftUI
|
|
3
|
+
#if !COCOAPODS
|
|
4
|
+
import BubblCore
|
|
5
|
+
#endif
|
|
6
|
+
|
|
7
|
+
/// Bubbl's notification screen: a card over the app, on a dimmed background, with the
|
|
8
|
+
/// notification's media, headline and body, its survey (a wizard, one question at a time) and its
|
|
9
|
+
/// call to action. The close button floats inside the card's top-right corner, 8 points in from
|
|
10
|
+
/// both edges. Laid out as Android's.
|
|
11
|
+
@available(iOS 17, *)
|
|
12
|
+
struct NotificationScreen: View {
|
|
13
|
+
@ObservedObject var model: NotificationModel
|
|
14
|
+
|
|
15
|
+
var body: some View {
|
|
16
|
+
GeometryReader { proxy in
|
|
17
|
+
ZStack {
|
|
18
|
+
Look.scrim.ignoresSafeArea()
|
|
19
|
+
ScrollView {
|
|
20
|
+
card
|
|
21
|
+
.frame(maxWidth: Look.cardMaxWidth)
|
|
22
|
+
.padding(.horizontal, 16)
|
|
23
|
+
.padding(.vertical, 24)
|
|
24
|
+
.frame(maxWidth: .infinity, minHeight: proxy.size.height)
|
|
25
|
+
}
|
|
26
|
+
.scrollDismissesKeyboard(.interactively)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
.onAppear { model.appear() }
|
|
30
|
+
.accessibilityAddTraits(.isModal)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
private var notification: BubblNotification { model.notification }
|
|
34
|
+
|
|
35
|
+
/// Audio plays from a row in the card; only a cover picture, if it has one, goes on top.
|
|
36
|
+
private var mediaOnTop: BubblNotification.Media? {
|
|
37
|
+
guard let media = notification.media else { return nil }
|
|
38
|
+
return media.kind != .audio || media.pictureUrl != nil ? media : nil
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
private var card: some View {
|
|
42
|
+
VStack(alignment: .leading, spacing: 0) {
|
|
43
|
+
if let media = mediaOnTop {
|
|
44
|
+
MediaHeader(model: model, media: media)
|
|
45
|
+
}
|
|
46
|
+
VStack(alignment: .leading, spacing: 0) {
|
|
47
|
+
Text(notification.headline)
|
|
48
|
+
.font(.title3.weight(.bold))
|
|
49
|
+
.foregroundStyle(Look.onSurface)
|
|
50
|
+
.padding(.trailing, mediaOnTop == nil ? 36 : 0)
|
|
51
|
+
.fixedSize(horizontal: false, vertical: true)
|
|
52
|
+
.accessibilityAddTraits(.isHeader)
|
|
53
|
+
if !notification.body.isEmpty {
|
|
54
|
+
Text(notification.body)
|
|
55
|
+
.font(.subheadline)
|
|
56
|
+
.foregroundStyle(Look.onSurfaceMuted)
|
|
57
|
+
.padding(.top, 8)
|
|
58
|
+
.fixedSize(horizontal: false, vertical: true)
|
|
59
|
+
}
|
|
60
|
+
if let media = notification.media, media.kind == .audio, let url = media.url.flatMap(URL.init(string:)) {
|
|
61
|
+
AudioRow(model: model, url: url).padding(.top, 16)
|
|
62
|
+
}
|
|
63
|
+
if model.hasSurvey {
|
|
64
|
+
SurveyWizard(model: model)
|
|
65
|
+
} else if let cta = notification.cta {
|
|
66
|
+
PrimaryButton(title: cta.label) { model.openCta() }
|
|
67
|
+
.padding(.top, 20)
|
|
68
|
+
.accessibilityIdentifier("bubbl.cta")
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
// Below the ribbon when no media is there to carry it.
|
|
72
|
+
.padding(.top, notification.sandbox && mediaOnTop == nil ? 18 : 0)
|
|
73
|
+
.padding(Look.cardPadding)
|
|
74
|
+
}
|
|
75
|
+
.frame(maxWidth: .infinity, alignment: .leading)
|
|
76
|
+
.background(Look.surface)
|
|
77
|
+
.clipShape(RoundedRectangle(cornerRadius: Look.cardRadius, style: .continuous))
|
|
78
|
+
.shadow(color: .black.opacity(0.18), radius: 16, y: 6)
|
|
79
|
+
.overlay(alignment: .topTrailing) {
|
|
80
|
+
CloseButton { model.dismiss() }.padding(8)
|
|
81
|
+
}
|
|
82
|
+
.overlay(alignment: .topLeading) {
|
|
83
|
+
if notification.sandbox { SandboxRibbon().padding(8) }
|
|
84
|
+
}
|
|
85
|
+
// A container: an identifier on it alone would replace its children's (the close button's).
|
|
86
|
+
.accessibilityElement(children: .contain)
|
|
87
|
+
.accessibilityIdentifier("bubbl.card")
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// MARK: - The survey
|
|
92
|
+
|
|
93
|
+
/// The survey as steps: progress, one question, Back and Next (Send on the last). Answers live in
|
|
94
|
+
/// the model, so going back keeps them. Sending shows a thank-you step, with the campaign's call
|
|
95
|
+
/// to action if it has one.
|
|
96
|
+
@available(iOS 17, *)
|
|
97
|
+
struct SurveyWizard: View {
|
|
98
|
+
@ObservedObject var model: NotificationModel
|
|
99
|
+
|
|
100
|
+
var body: some View {
|
|
101
|
+
if model.submitted {
|
|
102
|
+
thanks
|
|
103
|
+
} else {
|
|
104
|
+
questions
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private var questions: some View {
|
|
109
|
+
VStack(alignment: .leading, spacing: 0) {
|
|
110
|
+
Text(Words.progress(model.step + 1, of: model.questions.count))
|
|
111
|
+
.font(.footnote)
|
|
112
|
+
.foregroundStyle(Look.onSurfaceMuted)
|
|
113
|
+
ProgressView(value: Double(model.step + 1), total: Double(max(model.questions.count, 1)))
|
|
114
|
+
.tint(Look.accent)
|
|
115
|
+
.padding(.top, 6)
|
|
116
|
+
.accessibilityHidden(true)
|
|
117
|
+
if let question = model.questions[safe: model.step] {
|
|
118
|
+
QuestionView(model: model, question: question)
|
|
119
|
+
.id(question.id)
|
|
120
|
+
.padding(.top, 20)
|
|
121
|
+
}
|
|
122
|
+
if model.showingError {
|
|
123
|
+
Text(Words.answerRequired)
|
|
124
|
+
.font(.subheadline)
|
|
125
|
+
.foregroundStyle(Look.error)
|
|
126
|
+
.padding(.top, 12)
|
|
127
|
+
.accessibilityIdentifier("bubbl.error")
|
|
128
|
+
}
|
|
129
|
+
HStack(spacing: 12) {
|
|
130
|
+
// No Back on the first question: Next takes the whole row.
|
|
131
|
+
if model.step > 0 {
|
|
132
|
+
SecondaryButton(title: Words.back) { model.back() }
|
|
133
|
+
.frame(width: 96)
|
|
134
|
+
.accessibilityIdentifier("bubbl.back")
|
|
135
|
+
}
|
|
136
|
+
PrimaryButton(title: model.isLastStep ? Words.submit : Words.next) { model.next() }
|
|
137
|
+
.frame(maxWidth: .infinity)
|
|
138
|
+
.accessibilityIdentifier("bubbl.next")
|
|
139
|
+
}
|
|
140
|
+
.padding(.top, 20)
|
|
141
|
+
}
|
|
142
|
+
.padding(.top, 16)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private var thanks: some View {
|
|
146
|
+
VStack(alignment: .leading, spacing: 0) {
|
|
147
|
+
Text(Words.thanks)
|
|
148
|
+
.font(.headline)
|
|
149
|
+
.foregroundStyle(Look.onSurface)
|
|
150
|
+
.accessibilityIdentifier("bubbl.thanks")
|
|
151
|
+
if let cta = model.notification.cta {
|
|
152
|
+
PrimaryButton(title: cta.label) { model.openCta() }.padding(.top, 20)
|
|
153
|
+
SecondaryButton(title: Words.done) { model.finish() }.padding(.top, 4)
|
|
154
|
+
} else {
|
|
155
|
+
PrimaryButton(title: Words.done) { model.finish() }.padding(.top, 20)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
.padding(.top, 20)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/// One question, by its kind.
|
|
163
|
+
@available(iOS 17, *)
|
|
164
|
+
struct QuestionView: View {
|
|
165
|
+
@ObservedObject var model: NotificationModel
|
|
166
|
+
let question: BubblNotification.Question
|
|
167
|
+
|
|
168
|
+
var body: some View {
|
|
169
|
+
VStack(alignment: .leading, spacing: 8) {
|
|
170
|
+
Text(question.text + (question.required ? " *" : ""))
|
|
171
|
+
.font(.body.weight(.semibold))
|
|
172
|
+
.foregroundStyle(Look.onSurface)
|
|
173
|
+
.fixedSize(horizontal: false, vertical: true)
|
|
174
|
+
input
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
@ViewBuilder
|
|
179
|
+
private var input: some View {
|
|
180
|
+
switch question.kind {
|
|
181
|
+
case .openEnded:
|
|
182
|
+
TextField(Words.answerHint, text: typed { model.form.setText(question, $0) }, axis: .vertical)
|
|
183
|
+
.lineLimit(2...6)
|
|
184
|
+
.textFieldStyle(.roundedBorder)
|
|
185
|
+
.accessibilityIdentifier("bubbl.answer")
|
|
186
|
+
case .number:
|
|
187
|
+
TextField(Words.numberHint, text: typed { model.form.setNumber(question, $0) })
|
|
188
|
+
.keyboardType(.numbersAndPunctuation)
|
|
189
|
+
.textFieldStyle(.roundedBorder)
|
|
190
|
+
.accessibilityIdentifier("bubbl.answer")
|
|
191
|
+
case .singleChoice:
|
|
192
|
+
VStack(alignment: .leading, spacing: 0) {
|
|
193
|
+
ForEach(question.choices, id: \.id) { choice in
|
|
194
|
+
ChoiceRow(title: choice.text, selected: model.form.answer(question.id) == .string(choice.id), multiple: false) {
|
|
195
|
+
model.form.setChoice(question, choice.id)
|
|
196
|
+
model.answered()
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
case .multipleChoice:
|
|
201
|
+
VStack(alignment: .leading, spacing: 0) {
|
|
202
|
+
ForEach(question.choices, id: \.id) { choice in
|
|
203
|
+
let chosen = chosenIds.contains(choice.id)
|
|
204
|
+
ChoiceRow(title: choice.text, selected: chosen, multiple: true) {
|
|
205
|
+
model.form.toggleChoice(question, choice.id, chosen: !chosen)
|
|
206
|
+
model.answered()
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
case .boolean:
|
|
211
|
+
HStack(spacing: 12) {
|
|
212
|
+
ForEach([true, false], id: \.self) { value in
|
|
213
|
+
ChoiceRow(title: value ? Words.yes : Words.no, selected: model.form.answer(question.id) == .bool(value), multiple: false) {
|
|
214
|
+
model.form.setBoolean(question, value)
|
|
215
|
+
model.answered()
|
|
216
|
+
}
|
|
217
|
+
.frame(maxWidth: .infinity, alignment: .leading)
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
case .rating:
|
|
221
|
+
HStack(spacing: 0) {
|
|
222
|
+
ForEach(1...5, id: \.self) { value in
|
|
223
|
+
Button {
|
|
224
|
+
model.form.setRating(question, value)
|
|
225
|
+
model.answered()
|
|
226
|
+
} label: {
|
|
227
|
+
Image(systemName: "star.fill")
|
|
228
|
+
.font(.system(size: 30))
|
|
229
|
+
.foregroundStyle(value <= rating ? Look.accent : Look.outline)
|
|
230
|
+
.frame(maxWidth: .infinity, minHeight: 48)
|
|
231
|
+
.contentShape(Rectangle())
|
|
232
|
+
}
|
|
233
|
+
.buttonStyle(.plain)
|
|
234
|
+
.accessibilityLabel(Words.ratingStar(value))
|
|
235
|
+
.accessibilityAddTraits(value == rating ? .isSelected : [])
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
case .slider:
|
|
239
|
+
AnswerSlider(
|
|
240
|
+
value: model.form.answer(question.id)?.number,
|
|
241
|
+
range: SurveyForm.sliderMin...SurveyForm.sliderMax,
|
|
242
|
+
step: SurveyForm.sliderStep,
|
|
243
|
+
label: question.text
|
|
244
|
+
) { value in
|
|
245
|
+
model.form.setSlider(question, value)
|
|
246
|
+
model.answered()
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
private var chosenIds: [String] {
|
|
252
|
+
if case .array(let values)? = model.form.answer(question.id) { return values.compactMap(\.stringValue) }
|
|
253
|
+
return []
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
private var rating: Int {
|
|
257
|
+
model.form.answer(question.id)?.number.map { Int($0) } ?? 0
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/// Text as typed, kept in the model (so going back keeps it); the form gets it tidied.
|
|
261
|
+
private func typed(_ apply: @escaping (String) -> Void) -> Binding<String> {
|
|
262
|
+
Binding(
|
|
263
|
+
get: { model.typed[question.id] ?? "" },
|
|
264
|
+
set: { text in
|
|
265
|
+
model.typed[question.id] = text
|
|
266
|
+
apply(text)
|
|
267
|
+
model.answered()
|
|
268
|
+
}
|
|
269
|
+
)
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/// A choice: a radio button (one of several) or a checkbox (any of several), 48 points tall.
|
|
274
|
+
@available(iOS 17, *)
|
|
275
|
+
struct ChoiceRow: View {
|
|
276
|
+
let title: String
|
|
277
|
+
let selected: Bool
|
|
278
|
+
let multiple: Bool
|
|
279
|
+
let action: () -> Void
|
|
280
|
+
|
|
281
|
+
var body: some View {
|
|
282
|
+
Button(action: action) {
|
|
283
|
+
HStack(spacing: 12) {
|
|
284
|
+
Image(systemName: multiple ? (selected ? "checkmark.square.fill" : "square") : (selected ? "largecircle.fill.circle" : "circle"))
|
|
285
|
+
.font(.title3)
|
|
286
|
+
.foregroundStyle(selected ? Look.accent : Look.onSurfaceMuted)
|
|
287
|
+
Text(title)
|
|
288
|
+
.font(.subheadline)
|
|
289
|
+
.foregroundStyle(Look.onSurface)
|
|
290
|
+
.multilineTextAlignment(.leading)
|
|
291
|
+
Spacer(minLength: 0)
|
|
292
|
+
}
|
|
293
|
+
.frame(minHeight: 48)
|
|
294
|
+
.contentShape(Rectangle())
|
|
295
|
+
}
|
|
296
|
+
.buttonStyle(.plain)
|
|
297
|
+
.accessibilityAddTraits(selected ? .isSelected : [])
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/// The slider question: no handle until it's touched (a handle already placed would look like an
|
|
302
|
+
/// answer), then whole and half steps across the range, with the value shown under it.
|
|
303
|
+
@available(iOS 17, *)
|
|
304
|
+
struct AnswerSlider: View {
|
|
305
|
+
let value: Double?
|
|
306
|
+
let range: ClosedRange<Double>
|
|
307
|
+
let step: Double
|
|
308
|
+
let label: String
|
|
309
|
+
let onChange: (Double) -> Void
|
|
310
|
+
|
|
311
|
+
var body: some View {
|
|
312
|
+
VStack(alignment: .leading, spacing: 4) {
|
|
313
|
+
GeometryReader { proxy in
|
|
314
|
+
let width = max(proxy.size.width - 28, 1)
|
|
315
|
+
let fraction = value.map { ($0 - range.lowerBound) / (range.upperBound - range.lowerBound) } ?? 0
|
|
316
|
+
ZStack(alignment: .leading) {
|
|
317
|
+
Capsule().fill(Look.outline).frame(height: 4).padding(.horizontal, 14)
|
|
318
|
+
Capsule().fill(Look.accent).frame(width: value == nil ? 0 : width * fraction, height: 4).padding(.leading, 14)
|
|
319
|
+
if value != nil {
|
|
320
|
+
Circle().fill(Look.accent).frame(width: 28, height: 28).offset(x: width * fraction)
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
.frame(maxHeight: .infinity)
|
|
324
|
+
.contentShape(Rectangle())
|
|
325
|
+
.gesture(DragGesture(minimumDistance: 0).onChanged { drag in
|
|
326
|
+
let x = min(max(drag.location.x - 14, 0), width)
|
|
327
|
+
let raw = range.lowerBound + Double(x / width) * (range.upperBound - range.lowerBound)
|
|
328
|
+
onChange((raw / step).rounded() * step)
|
|
329
|
+
})
|
|
330
|
+
}
|
|
331
|
+
.frame(height: 48)
|
|
332
|
+
Text(value.map(Self.format) ?? Words.sliderUnanswered)
|
|
333
|
+
.font(.subheadline)
|
|
334
|
+
.foregroundStyle(Look.onSurfaceMuted)
|
|
335
|
+
}
|
|
336
|
+
.accessibilityElement()
|
|
337
|
+
.accessibilityLabel(label)
|
|
338
|
+
.accessibilityValue(value.map(Self.format) ?? Words.sliderUnanswered)
|
|
339
|
+
.accessibilityAdjustableAction { direction in
|
|
340
|
+
let current = value ?? range.lowerBound
|
|
341
|
+
switch direction {
|
|
342
|
+
case .increment: onChange(min(current + step, range.upperBound))
|
|
343
|
+
case .decrement: onChange(max(current - step, range.lowerBound))
|
|
344
|
+
@unknown default: break
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
.accessibilityIdentifier("bubbl.slider")
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
static func format(_ value: Double) -> String {
|
|
351
|
+
value == value.rounded() ? String(Int(value)) : String(value)
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// MARK: - Buttons
|
|
356
|
+
|
|
357
|
+
@available(iOS 17, *)
|
|
358
|
+
struct PrimaryButton: View {
|
|
359
|
+
let title: String
|
|
360
|
+
let action: () -> Void
|
|
361
|
+
|
|
362
|
+
var body: some View {
|
|
363
|
+
Button(action: action) {
|
|
364
|
+
Text(title)
|
|
365
|
+
.font(.body.weight(.semibold))
|
|
366
|
+
.foregroundStyle(Look.onAccent)
|
|
367
|
+
.frame(maxWidth: .infinity, minHeight: 48)
|
|
368
|
+
.background(Look.accent, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
|
|
369
|
+
}
|
|
370
|
+
.buttonStyle(.plain)
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
@available(iOS 17, *)
|
|
375
|
+
struct SecondaryButton: View {
|
|
376
|
+
let title: String
|
|
377
|
+
let action: () -> Void
|
|
378
|
+
|
|
379
|
+
var body: some View {
|
|
380
|
+
Button(action: action) {
|
|
381
|
+
Text(title)
|
|
382
|
+
.font(.body.weight(.semibold))
|
|
383
|
+
.foregroundStyle(Look.accent)
|
|
384
|
+
.frame(maxWidth: .infinity, minHeight: 48)
|
|
385
|
+
.contentShape(Rectangle())
|
|
386
|
+
}
|
|
387
|
+
.buttonStyle(.plain)
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/// The ✕ that floats in the card's top-right corner, over any media.
|
|
392
|
+
@available(iOS 17, *)
|
|
393
|
+
struct CloseButton: View {
|
|
394
|
+
let action: () -> Void
|
|
395
|
+
|
|
396
|
+
var body: some View {
|
|
397
|
+
Button(action: action) {
|
|
398
|
+
Image(systemName: "xmark")
|
|
399
|
+
.font(.system(size: 15, weight: .semibold))
|
|
400
|
+
.foregroundStyle(Look.onSurface)
|
|
401
|
+
.frame(width: 36, height: 36)
|
|
402
|
+
.background(Look.surface, in: Circle())
|
|
403
|
+
.shadow(color: .black.opacity(0.2), radius: 4, y: 1)
|
|
404
|
+
}
|
|
405
|
+
.buttonStyle(.plain)
|
|
406
|
+
.accessibilityLabel(Words.close)
|
|
407
|
+
.accessibilityIdentifier("bubbl.close")
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/// The SANDBOX mark on a notification from a Sandbox workspace, in the card's top-left corner (the
|
|
412
|
+
/// close button has the right): so nobody mistakes a test for the real thing. Not themeable.
|
|
413
|
+
@available(iOS 17, *)
|
|
414
|
+
struct SandboxRibbon: View {
|
|
415
|
+
var body: some View {
|
|
416
|
+
Text("SANDBOX")
|
|
417
|
+
.font(.system(size: 11, weight: .heavy))
|
|
418
|
+
.tracking(1)
|
|
419
|
+
.foregroundStyle(Color(red: 0.23, green: 0.15, blue: 0))
|
|
420
|
+
.padding(.horizontal, 8)
|
|
421
|
+
.padding(.vertical, 4)
|
|
422
|
+
.background(Color(red: 0.98, green: 0.72, blue: 0.13), in: Capsule())
|
|
423
|
+
.shadow(color: .black.opacity(0.2), radius: 3, y: 1)
|
|
424
|
+
.accessibilityLabel("Sandbox")
|
|
425
|
+
.accessibilityIdentifier("bubbl.sandbox")
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
#endif
|