@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.
Files changed (176) hide show
  1. package/BubblReactNativeSdk.podspec +29 -9
  2. package/CHANGELOG.md +52 -1
  3. package/LICENSE +202 -1
  4. package/README.md +172 -83
  5. package/android/build.gradle +33 -30
  6. package/android/src/main/AndroidManifest.xml +1 -0
  7. package/android/src/main/java/tech/bubbl/reactnative/BubblJson.kt +135 -0
  8. package/android/src/main/java/tech/bubbl/reactnative/BubblSdkModule.kt +277 -0
  9. package/android/src/main/java/tech/bubbl/reactnative/BubblSdkPackage.kt +26 -0
  10. package/app.plugin.js +2 -0
  11. package/ios/BubblPush.swift +44 -0
  12. package/ios/BubblSdk.h +7 -0
  13. package/ios/BubblSdk.mm +252 -0
  14. package/ios/BubblSdkImpl.swift +335 -0
  15. package/ios-extension/BubblReactNativeNotificationService.podspec +44 -0
  16. package/ios-extension/Sources/BubblCore/API/ApiResponse.swift +90 -0
  17. package/ios-extension/Sources/BubblCore/API/CredentialStore.swift +116 -0
  18. package/ios-extension/Sources/BubblCore/API/DeviceApiClient.swift +239 -0
  19. package/ios-extension/Sources/BubblCore/API/ErrorActions.swift +68 -0
  20. package/ios-extension/Sources/BubblCore/API/Http.swift +39 -0
  21. package/ios-extension/Sources/BubblCore/API/ServerClock.swift +69 -0
  22. package/ios-extension/Sources/BubblCore/BubblVersion.swift +5 -0
  23. package/ios-extension/Sources/BubblCore/Device/ConfigSync.swift +216 -0
  24. package/ios-extension/Sources/BubblCore/Device/DeviceSync.swift +160 -0
  25. package/ios-extension/Sources/BubblCore/Device/ProvisioningProfile.swift +24 -0
  26. package/ios-extension/Sources/BubblCore/Engine/EngineCore.swift +523 -0
  27. package/ios-extension/Sources/BubblCore/Engine/Geofencing.swift +170 -0
  28. package/ios-extension/Sources/BubblCore/Engine/Permissions.swift +90 -0
  29. package/ios-extension/Sources/BubblCore/Engine/Privacy.swift +64 -0
  30. package/ios-extension/Sources/BubblCore/Engine/PrivacyControls.swift +184 -0
  31. package/ios-extension/Sources/BubblCore/Engine/Pushes.swift +61 -0
  32. package/ios-extension/Sources/BubblCore/Engine/Sandbox.swift +149 -0
  33. package/ios-extension/Sources/BubblCore/Events/EventQueue.swift +207 -0
  34. package/ios-extension/Sources/BubblCore/Events/EventStore.swift +159 -0
  35. package/ios-extension/Sources/BubblCore/Geofence/Geofence.swift +160 -0
  36. package/ios-extension/Sources/BubblCore/Geofence/GeofenceEngine.swift +488 -0
  37. package/ios-extension/Sources/BubblCore/Geofence/GeofenceState.swift +103 -0
  38. package/ios-extension/Sources/BubblCore/Geofence/RegionBudget.swift +62 -0
  39. package/ios-extension/Sources/BubblCore/Notification/BubblNotification.swift +137 -0
  40. package/ios-extension/Sources/BubblCore/Notification/NotificationEvents.swift +160 -0
  41. package/ios-extension/Sources/BubblCore/Notification/Push.swift +134 -0
  42. package/ios-extension/Sources/BubblCore/Notification/SurveyForm.swift +185 -0
  43. package/ios-extension/Sources/BubblCore/RequestSigner.swift +56 -0
  44. package/ios-extension/Sources/BubblCore/Support/AsyncMutex.swift +39 -0
  45. package/ios-extension/Sources/BubblCore/Support/BubblLog.swift +52 -0
  46. package/ios-extension/Sources/BubblCore/Support/JSON.swift +31 -0
  47. package/ios-extension/Sources/BubblCore/Support/JSONValue.swift +94 -0
  48. package/ios-extension/Sources/BubblCore/Support/Locking.swift +11 -0
  49. package/ios-extension/Sources/BubblCore/Support/ValueStore.swift +109 -0
  50. package/ios-extension/Sources/BubblNotificationService/BubblNotificationService.swift +124 -0
  51. package/ios-sdk/BubblCore/API/ApiResponse.swift +90 -0
  52. package/ios-sdk/BubblCore/API/CredentialStore.swift +116 -0
  53. package/ios-sdk/BubblCore/API/DeviceApiClient.swift +239 -0
  54. package/ios-sdk/BubblCore/API/ErrorActions.swift +68 -0
  55. package/ios-sdk/BubblCore/API/Http.swift +39 -0
  56. package/ios-sdk/BubblCore/API/ServerClock.swift +69 -0
  57. package/ios-sdk/BubblCore/BubblVersion.swift +5 -0
  58. package/ios-sdk/BubblCore/Device/ConfigSync.swift +216 -0
  59. package/ios-sdk/BubblCore/Device/DeviceSync.swift +160 -0
  60. package/ios-sdk/BubblCore/Device/ProvisioningProfile.swift +24 -0
  61. package/ios-sdk/BubblCore/Engine/EngineCore.swift +523 -0
  62. package/ios-sdk/BubblCore/Engine/Geofencing.swift +170 -0
  63. package/ios-sdk/BubblCore/Engine/Permissions.swift +90 -0
  64. package/ios-sdk/BubblCore/Engine/Privacy.swift +64 -0
  65. package/ios-sdk/BubblCore/Engine/PrivacyControls.swift +184 -0
  66. package/ios-sdk/BubblCore/Engine/Pushes.swift +61 -0
  67. package/ios-sdk/BubblCore/Engine/Sandbox.swift +149 -0
  68. package/ios-sdk/BubblCore/Events/EventQueue.swift +207 -0
  69. package/ios-sdk/BubblCore/Events/EventStore.swift +159 -0
  70. package/ios-sdk/BubblCore/Geofence/Geofence.swift +160 -0
  71. package/ios-sdk/BubblCore/Geofence/GeofenceEngine.swift +488 -0
  72. package/ios-sdk/BubblCore/Geofence/GeofenceState.swift +103 -0
  73. package/ios-sdk/BubblCore/Geofence/RegionBudget.swift +62 -0
  74. package/ios-sdk/BubblCore/Notification/BubblNotification.swift +137 -0
  75. package/ios-sdk/BubblCore/Notification/NotificationEvents.swift +160 -0
  76. package/ios-sdk/BubblCore/Notification/Push.swift +134 -0
  77. package/ios-sdk/BubblCore/Notification/SurveyForm.swift +185 -0
  78. package/ios-sdk/BubblCore/RequestSigner.swift +56 -0
  79. package/ios-sdk/BubblCore/Support/AsyncMutex.swift +39 -0
  80. package/ios-sdk/BubblCore/Support/BubblLog.swift +52 -0
  81. package/ios-sdk/BubblCore/Support/JSON.swift +31 -0
  82. package/ios-sdk/BubblCore/Support/JSONValue.swift +94 -0
  83. package/ios-sdk/BubblCore/Support/Locking.swift +11 -0
  84. package/ios-sdk/BubblCore/Support/ValueStore.swift +109 -0
  85. package/ios-sdk/BubblLaunch/BubblLaunch.m +45 -0
  86. package/ios-sdk/BubblLaunch/include/BubblLaunch.h +5 -0
  87. package/ios-sdk/BubblSDK/Bubbl.swift +308 -0
  88. package/ios-sdk/BubblSDK/BubblBackend.swift +118 -0
  89. package/ios-sdk/BubblSDK/BubblCredential.swift +30 -0
  90. package/ios-sdk/BubblSDK/BubblMessage.swift +151 -0
  91. package/ios-sdk/BubblSDK/BubblOptions.swift +39 -0
  92. package/ios-sdk/BubblSDK/BubblPermissions.swift +79 -0
  93. package/ios-sdk/BubblSDK/BubblTestDeviceResult.swift +10 -0
  94. package/ios-sdk/BubblSDK/Engine/BackgroundRefresh.swift +92 -0
  95. package/ios-sdk/BubblSDK/Engine/EngineHost+Geofences.swift +177 -0
  96. package/ios-sdk/BubblSDK/Engine/EngineHost+Privacy.swift +65 -0
  97. package/ios-sdk/BubblSDK/Engine/EngineHost+Push.swift +154 -0
  98. package/ios-sdk/BubblSDK/Engine/EngineHost.swift +356 -0
  99. package/ios-sdk/BubblSDK/Engine/SupportedBubbl.swift +320 -0
  100. package/ios-sdk/BubblSDK/Engine/SystemLog.swift +27 -0
  101. package/ios-sdk/BubblSDK/Engine/WorkRunner.swift +107 -0
  102. package/ios-sdk/BubblSDK/Notification/Look.swift +78 -0
  103. package/ios-sdk/BubblSDK/Notification/NotificationMedia.swift +329 -0
  104. package/ios-sdk/BubblSDK/Notification/NotificationModel.swift +172 -0
  105. package/ios-sdk/BubblSDK/Notification/NotificationScreen.swift +428 -0
  106. package/ios-sdk/BubblSDK/Notification/ScreenPresenter.swift +148 -0
  107. package/ios-sdk/BubblSDK/Permissions/PermissionFlow.swift +169 -0
  108. package/ios-sdk/BubblSDK/Permissions/PrivacyView.swift +110 -0
  109. package/ios-sdk/BubblSDK/Platform/AppleDevicePlatform.swift +95 -0
  110. package/ios-sdk/BubblSDK/Platform/KeychainCredentialStore.swift +114 -0
  111. package/ios-sdk/BubblSDK/Platform/LocationService.swift +418 -0
  112. package/ios-sdk/BubblSDK/Platform/PushIntegration.swift +288 -0
  113. package/ios-sdk/BubblSDK/Platform/URLSessionHttpClient.swift +69 -0
  114. package/ios-sdk/BubblSDK/PrivacyInfo.xcprivacy +76 -0
  115. package/jest/index.js +75 -0
  116. package/lib/commonjs/BubblModule.js +14 -0
  117. package/lib/commonjs/BubblModule.js.map +1 -0
  118. package/lib/commonjs/BubblModule.web.js +13 -0
  119. package/lib/commonjs/BubblModule.web.js.map +1 -0
  120. package/lib/commonjs/NativeBubblSdk.js +20 -0
  121. package/lib/commonjs/NativeBubblSdk.js.map +1 -0
  122. package/lib/commonjs/index.js +336 -0
  123. package/lib/commonjs/index.js.map +1 -0
  124. package/lib/commonjs/package.json +1 -0
  125. package/lib/commonjs/types.js +2 -0
  126. package/lib/commonjs/types.js.map +1 -0
  127. package/lib/module/BubblModule.js +5 -0
  128. package/lib/module/BubblModule.js.map +1 -0
  129. package/lib/module/BubblModule.web.js +9 -0
  130. package/lib/module/BubblModule.web.js.map +1 -0
  131. package/lib/module/NativeBubblSdk.js +18 -0
  132. package/lib/module/NativeBubblSdk.js.map +1 -0
  133. package/lib/module/index.js +331 -0
  134. package/lib/module/index.js.map +1 -0
  135. package/lib/module/package.json +1 -0
  136. package/lib/module/types.js +2 -0
  137. package/lib/module/types.js.map +1 -0
  138. package/lib/typescript/commonjs/package.json +1 -0
  139. package/lib/typescript/commonjs/src/BubblModule.d.ts +3 -0
  140. package/lib/typescript/commonjs/src/BubblModule.d.ts.map +1 -0
  141. package/lib/typescript/commonjs/src/BubblModule.web.d.ts +8 -0
  142. package/lib/typescript/commonjs/src/BubblModule.web.d.ts.map +1 -0
  143. package/lib/typescript/commonjs/src/NativeBubblSdk.d.ts +59 -0
  144. package/lib/typescript/commonjs/src/NativeBubblSdk.d.ts.map +1 -0
  145. package/lib/typescript/commonjs/src/index.d.ts +111 -0
  146. package/lib/typescript/commonjs/src/index.d.ts.map +1 -0
  147. package/lib/typescript/commonjs/src/types.d.ts +192 -0
  148. package/lib/typescript/commonjs/src/types.d.ts.map +1 -0
  149. package/lib/typescript/module/package.json +1 -0
  150. package/lib/typescript/module/src/BubblModule.d.ts +3 -0
  151. package/lib/typescript/module/src/BubblModule.d.ts.map +1 -0
  152. package/lib/typescript/module/src/BubblModule.web.d.ts +8 -0
  153. package/lib/typescript/module/src/BubblModule.web.d.ts.map +1 -0
  154. package/lib/typescript/module/src/NativeBubblSdk.d.ts +59 -0
  155. package/lib/typescript/module/src/NativeBubblSdk.d.ts.map +1 -0
  156. package/lib/typescript/module/src/index.d.ts +111 -0
  157. package/lib/typescript/module/src/index.d.ts.map +1 -0
  158. package/lib/typescript/module/src/types.d.ts +192 -0
  159. package/lib/typescript/module/src/types.d.ts.map +1 -0
  160. package/package.json +105 -24
  161. package/plugin/build/imageNotifications.d.ts +35 -0
  162. package/plugin/build/imageNotifications.js +198 -0
  163. package/plugin/build/index.d.ts +84 -0
  164. package/plugin/build/index.js +176 -0
  165. package/src/BubblModule.ts +2 -0
  166. package/src/BubblModule.web.ts +9 -0
  167. package/src/NativeBubblSdk.ts +67 -0
  168. package/src/index.ts +338 -282
  169. package/src/types.ts +182 -0
  170. package/android/src/main/kotlin/tech/bubbl/reactnative/BubblSdkModule.kt +0 -680
  171. package/android/src/main/kotlin/tech/bubbl/reactnative/BubblSdkNotificationIntents.kt +0 -32
  172. package/android/src/main/kotlin/tech/bubbl/reactnative/BubblSdkPackage.kt +0 -14
  173. package/ios/BubblSdk.swift +0 -802
  174. package/ios/BubblSdkBridge.m +0 -86
  175. package/react-native.config.js +0 -11
  176. 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