@capgo/capacitor-pretty-toast 8.1.0

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 (85) hide show
  1. package/CapgoCapacitorPrettyToast.podspec +17 -0
  2. package/LICENSE +373 -0
  3. package/Package.swift +28 -0
  4. package/README.md +341 -0
  5. package/android/build.gradle +71 -0
  6. package/android/src/main/AndroidManifest.xml +6 -0
  7. package/android/src/main/java/com/toast/PrettyToastPlugin.kt +197 -0
  8. package/android/src/main/java/com/toast/ToastOverlay.kt +495 -0
  9. package/android/src/main/java/com/toast/anim/CutoutMorphAnimator.kt +235 -0
  10. package/android/src/main/java/com/toast/anim/SlideAnimator.kt +64 -0
  11. package/android/src/main/java/com/toast/anim/ToastAnimator.kt +23 -0
  12. package/android/src/main/java/com/toast/backdrop/BackdropSampler.kt +142 -0
  13. package/android/src/main/java/com/toast/backdrop/OutlineController.kt +100 -0
  14. package/android/src/main/java/com/toast/cutout/CutoutDetector.kt +88 -0
  15. package/android/src/main/java/com/toast/cutout/CutoutInfo.kt +28 -0
  16. package/android/src/main/java/com/toast/gesture/ToastGestureHandler.kt +68 -0
  17. package/android/src/main/java/com/toast/ui/IconMapper.kt +26 -0
  18. package/android/src/main/java/com/toast/ui/PassThroughFrameLayout.kt +53 -0
  19. package/android/src/main/java/com/toast/ui/ToastViewFactory.kt +224 -0
  20. package/android/src/main/java/com/toast/util/Density.kt +17 -0
  21. package/android/src/main/java/com/toast/util/StatusBarController.kt +24 -0
  22. package/android/src/main/java/com/toast/util/ToastConstants.kt +36 -0
  23. package/android/src/main/res/.gitkeep +0 -0
  24. package/android/src/main/res/drawable/ic_arrow_downward.xml +9 -0
  25. package/android/src/main/res/drawable/ic_arrow_upward.xml +9 -0
  26. package/android/src/main/res/drawable/ic_cancel.xml +9 -0
  27. package/android/src/main/res/drawable/ic_check_circle.xml +9 -0
  28. package/android/src/main/res/drawable/ic_favorite.xml +9 -0
  29. package/android/src/main/res/drawable/ic_info.xml +9 -0
  30. package/android/src/main/res/drawable/ic_mail.xml +9 -0
  31. package/android/src/main/res/drawable/ic_notifications.xml +9 -0
  32. package/android/src/main/res/drawable/ic_touch_app.xml +9 -0
  33. package/android/src/main/res/drawable/ic_warning.xml +9 -0
  34. package/android/src/main/res/drawable/ic_wifi.xml +9 -0
  35. package/android/src/main/res/values/colors.xml +3 -0
  36. package/android/src/main/res/values/strings.xml +3 -0
  37. package/android/src/main/res/values/styles.xml +3 -0
  38. package/android/src/test/java/com/toast/PrettyToastPluginTest.kt +26 -0
  39. package/dist/docs.json +459 -0
  40. package/dist/esm/controller.d.ts +30 -0
  41. package/dist/esm/controller.js +271 -0
  42. package/dist/esm/controller.js.map +1 -0
  43. package/dist/esm/definitions.d.ts +144 -0
  44. package/dist/esm/definitions.js +2 -0
  45. package/dist/esm/definitions.js.map +1 -0
  46. package/dist/esm/driver.d.ts +19 -0
  47. package/dist/esm/driver.js +24 -0
  48. package/dist/esm/driver.js.map +1 -0
  49. package/dist/esm/icons.d.ts +14 -0
  50. package/dist/esm/icons.js +138 -0
  51. package/dist/esm/icons.js.map +1 -0
  52. package/dist/esm/index.d.ts +2 -0
  53. package/dist/esm/index.js +2 -0
  54. package/dist/esm/index.js.map +1 -0
  55. package/dist/esm/internal-plugin.d.ts +2 -0
  56. package/dist/esm/internal-plugin.js +5 -0
  57. package/dist/esm/internal-plugin.js.map +1 -0
  58. package/dist/esm/internal-types.d.ts +31 -0
  59. package/dist/esm/internal-types.js +2 -0
  60. package/dist/esm/internal-types.js.map +1 -0
  61. package/dist/esm/toast.d.ts +1 -0
  62. package/dist/esm/toast.js +5 -0
  63. package/dist/esm/toast.js.map +1 -0
  64. package/dist/esm/web-renderer.d.ts +36 -0
  65. package/dist/esm/web-renderer.js +296 -0
  66. package/dist/esm/web-renderer.js.map +1 -0
  67. package/dist/esm/web.d.ts +10 -0
  68. package/dist/esm/web.js +28 -0
  69. package/dist/esm/web.js.map +1 -0
  70. package/dist/plugin.cjs.js +770 -0
  71. package/dist/plugin.cjs.js.map +1 -0
  72. package/dist/plugin.js +773 -0
  73. package/dist/plugin.js.map +1 -0
  74. package/ios/Sources/PrettyToastPlugin/CustomHostingView.swift +13 -0
  75. package/ios/Sources/PrettyToastPlugin/PassThroughWindow.swift +143 -0
  76. package/ios/Sources/PrettyToastPlugin/PrettyToastColorParser.swift +94 -0
  77. package/ios/Sources/PrettyToastPlugin/PrettyToastPlugin.swift +138 -0
  78. package/ios/Sources/PrettyToastPlugin/PrettyToastView.swift +267 -0
  79. package/ios/Sources/PrettyToastPlugin/Toast.swift +29 -0
  80. package/ios/Sources/PrettyToastPlugin/ToastManager.swift +392 -0
  81. package/ios/Tests/PrettyToastPluginTests/PrettyToastPluginTests.swift +21 -0
  82. package/package.json +98 -0
  83. package/scripts/check-capacitor-plugin-wiring.mjs +254 -0
  84. package/scripts/deploy-example-capgo.mjs +86 -0
  85. package/scripts/test-ios.sh +14 -0
@@ -0,0 +1,267 @@
1
+ import SwiftUI
2
+
3
+ struct PrettyToastView: View {
4
+ @ObservedObject var window: PassThroughWindow
5
+ @State private var measuredContentHeight: CGFloat = 0
6
+
7
+ var body: some View {
8
+ GeometryReader {
9
+ let safeArea = $0.safeAreaInsets
10
+ let size = $0.size
11
+
12
+ let haveDynamicIsland: Bool = safeArea.top >= 59 && window.useDynamicIsland
13
+ let dynamicIslandWidth: CGFloat = 120
14
+ let dynamicIslandHeight: CGFloat = 36
15
+ let topOffset: CGFloat = 11 + max((safeArea.top - 59), 0)
16
+ // Nudge up 0.5pt so the centered stroke clears the DI's top line
17
+ // instead of sitting half-behind it.
18
+ let expandedTopOffset: CGFloat = topOffset - 0.5
19
+
20
+ let expandedWidth = size.width - (topOffset * 2)
21
+ let baseHeight: CGFloat = haveDynamicIsland ? 90 : 70
22
+ let baseContentArea: CGFloat = haveDynamicIsland ? (baseHeight - dynamicIslandHeight - 12) : (baseHeight - 20)
23
+ let overflow = max(0, measuredContentHeight - baseContentArea)
24
+ let expandedHeight: CGFloat = baseHeight + overflow
25
+
26
+ let scaleX: CGFloat = isExpanded ? 1 : (dynamicIslandWidth / expandedWidth)
27
+ let scaleY: CGFloat = isExpanded ? 1 : (dynamicIslandHeight / expandedHeight)
28
+
29
+ ZStack {
30
+ toastBackground()
31
+ .overlay {
32
+ toastContent(haveDynamicIsland, expandedWidth: expandedWidth)
33
+ .frame(width: expandedWidth, height: expandedHeight)
34
+ .scaleEffect(x: scaleX, y: scaleY)
35
+ }
36
+ .frame(
37
+ width: isExpanded ? expandedWidth : dynamicIslandWidth,
38
+ height: isExpanded ? expandedHeight : dynamicIslandHeight
39
+ )
40
+ .opacity(haveDynamicIsland ? 1 : (isExpanded ? 1 : 0))
41
+ .modifier(CapsuleOpacityModifier(
42
+ haveDynamicIsland: haveDynamicIsland,
43
+ isExpanded: isExpanded
44
+ ))
45
+ .modifier(GeometryGroupModifier())
46
+ .contentShape(Rectangle())
47
+ .onTapGesture {
48
+ window.wasTapped = true
49
+ }
50
+ .gesture(
51
+ DragGesture(minimumDistance: 2).onEnded { value in
52
+ if value.translation.height < -8 || value.predictedEndTranslation.height < -40 {
53
+ window.isPresented = false
54
+ }
55
+ }
56
+ )
57
+ .offset(y: haveDynamicIsland ? (isExpanded ? expandedTopOffset : topOffset) : 0)
58
+ }
59
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
60
+ .padding(.top, haveDynamicIsland ? 0 : (isExpanded ? max(safeArea.top, 10) : 0))
61
+ .ignoresSafeArea()
62
+ .animation(.bouncy(duration: 0.3, extraBounce: 0), value: isExpanded)
63
+ }
64
+ }
65
+
66
+ @ViewBuilder
67
+ func toastContent(_ haveDynamicIsland: Bool, expandedWidth: CGFloat) -> some View {
68
+ if let toast = window.toast {
69
+ VStack(spacing: 0) {
70
+ if haveDynamicIsland && !toast.message.isEmpty {
71
+ Spacer(minLength: 0)
72
+ }
73
+
74
+ HStack(spacing: 10) {
75
+ ToastIconView(toast: toast, isExpanded: isExpanded)
76
+ .frame(width: 50)
77
+
78
+ VStack(alignment: .leading, spacing: 4) {
79
+ Text(toast.title)
80
+ .font(.callout)
81
+ .fontWeight(.semibold)
82
+ .foregroundStyle(.white)
83
+
84
+ if !toast.message.isEmpty {
85
+ Text(toast.message)
86
+ .font(.caption)
87
+ .foregroundColor(.white.opacity(0.6))
88
+ }
89
+ }
90
+ .frame(maxWidth: .infinity, alignment: .leading)
91
+
92
+ if let label = toast.actionLabel, !label.isEmpty {
93
+ Button(action: { window.actionTapped = true }) {
94
+ Text(label)
95
+ .font(.footnote)
96
+ .fontWeight(.semibold)
97
+ .foregroundStyle(toast.accentColor)
98
+ .padding(.horizontal, 12)
99
+ .padding(.vertical, 6)
100
+ .background(
101
+ Capsule().fill(Color.white.opacity(0.12))
102
+ )
103
+ }
104
+ .buttonStyle(.plain)
105
+ }
106
+ }
107
+ }
108
+ .padding(.horizontal, 20)
109
+ .padding(.bottom, haveDynamicIsland && !toast.message.isEmpty ? 12 : 0)
110
+ .compositingGroup()
111
+ .blur(radius: isExpanded ? 0 : 5)
112
+ .opacity(isExpanded ? 1 : 0)
113
+
114
+ // Hidden measurer — drives measuredContentHeight so the pill grows
115
+ // for overflowing text.
116
+ HStack(spacing: 10) {
117
+ Color.clear.frame(width: 50, height: 1)
118
+
119
+ VStack(alignment: .leading, spacing: 4) {
120
+ Text(toast.title)
121
+ .font(.callout)
122
+ .fontWeight(.semibold)
123
+
124
+ if !toast.message.isEmpty {
125
+ Text(toast.message)
126
+ .font(.caption)
127
+ }
128
+ }
129
+ .frame(maxWidth: .infinity, alignment: .leading)
130
+ }
131
+ .padding(.horizontal, 20)
132
+ .fixedSize(horizontal: false, vertical: true)
133
+ .background(
134
+ GeometryReader { geo in
135
+ Color.clear.preference(
136
+ key: ContentHeightKey.self,
137
+ value: geo.size.height
138
+ )
139
+ }
140
+ )
141
+ .hidden()
142
+ .onPreferenceChange(ContentHeightKey.self) { height in
143
+ measuredContentHeight = height
144
+ }
145
+ }
146
+ }
147
+
148
+ private func toastBackground() -> some View {
149
+ let accent = window.toast?.accentColor ?? .white
150
+ let strokeOverride = window.toast?.strokeOverride
151
+ let disableSampling = window.toast?.disableBackdropSampling ?? false
152
+ let tint: BackdropTint = disableSampling ? .gray : window.backdropTint
153
+ return makeStrokeBackground(
154
+ shape: RoundedRectangle(cornerRadius: 30, style: .continuous),
155
+ accent: accent,
156
+ strokeOverride: strokeOverride,
157
+ tint: tint
158
+ )
159
+ }
160
+
161
+ private func makeStrokeBackground<S: Shape>(
162
+ shape: S,
163
+ accent: Color,
164
+ strokeOverride: Color?,
165
+ tint: BackdropTint
166
+ ) -> some View {
167
+ shape
168
+ .fill(.black)
169
+ .overlay {
170
+ ZStack {
171
+ if let override = strokeOverride {
172
+ strokeLayer(shape: shape, color: override, alpha: 1.0, visible: isExpanded)
173
+ } else {
174
+ strokeLayer(shape: shape, color: accent, alpha: 0.2, visible: isExpanded && tint == .colored)
175
+ strokeLayer(shape: shape, color: .white, alpha: 0.06, visible: isExpanded && tint == .gray)
176
+ }
177
+ }
178
+ }
179
+ }
180
+
181
+ @ViewBuilder
182
+ private func strokeLayer<S: Shape>(shape: S, color: Color, alpha: Double, visible: Bool) -> some View {
183
+ let stroke = shape.stroke(color.opacity(alpha), lineWidth: 1.5)
184
+ if #available(iOS 17, *) {
185
+ // Scope easeInOut to opacity only; frame changes keep the bouncy
186
+ // ambient animation so the stroke tracks the pill geometry.
187
+ stroke.animation(.easeInOut(duration: 0.3)) { view in
188
+ view.opacity(visible ? 1 : 0)
189
+ }
190
+ } else {
191
+ stroke
192
+ .opacity(visible ? 1 : 0)
193
+ .animation(.easeInOut(duration: 0.3), value: visible)
194
+ }
195
+ }
196
+
197
+ var isExpanded: Bool {
198
+ window.isPresented
199
+ }
200
+ }
201
+
202
+ struct ToastIconView: View {
203
+ let toast: Toast
204
+ let isExpanded: Bool
205
+
206
+ var body: some View {
207
+ if let image = toast.customIcon {
208
+ Image(uiImage: image)
209
+ .resizable()
210
+ .renderingMode(.original)
211
+ .aspectRatio(contentMode: .fit)
212
+ .frame(width: 35, height: 35)
213
+ } else {
214
+ Image(systemName: toast.symbol)
215
+ .font(toast.symbolFont)
216
+ .foregroundStyle(toast.symbolForegroundStyle.0, toast.symbolForegroundStyle.1)
217
+ .modifier(WiggleModifier(isExpanded: isExpanded))
218
+ }
219
+ }
220
+ }
221
+
222
+ private struct ContentHeightKey: PreferenceKey {
223
+ static var defaultValue: CGFloat = 0
224
+ static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
225
+ value = max(value, nextValue())
226
+ }
227
+ }
228
+
229
+ private struct WiggleModifier: ViewModifier {
230
+ let isExpanded: Bool
231
+
232
+ func body(content: Content) -> some View {
233
+ if #available(iOS 18, *) {
234
+ content.symbolEffect(.wiggle, value: isExpanded)
235
+ } else {
236
+ content
237
+ }
238
+ }
239
+ }
240
+
241
+ private struct CapsuleOpacityModifier: ViewModifier {
242
+ let haveDynamicIsland: Bool
243
+ let isExpanded: Bool
244
+
245
+ func body(content: Content) -> some View {
246
+ if #available(iOS 17, *) {
247
+ content
248
+ .animation(.linear(duration: 0.02).delay(isExpanded ? 0 : 0.28)) { inner in
249
+ inner.opacity(haveDynamicIsland ? (isExpanded ? 1 : 0) : 1)
250
+ }
251
+ } else {
252
+ content
253
+ .opacity(haveDynamicIsland ? (isExpanded ? 1 : 0) : 1)
254
+ .animation(.linear(duration: 0.02).delay(isExpanded ? 0 : 0.28), value: isExpanded)
255
+ }
256
+ }
257
+ }
258
+
259
+ private struct GeometryGroupModifier: ViewModifier {
260
+ func body(content: Content) -> some View {
261
+ if #available(iOS 17, *) {
262
+ content.geometryGroup()
263
+ } else {
264
+ content
265
+ }
266
+ }
267
+ }
@@ -0,0 +1,29 @@
1
+ import SwiftUI
2
+ import UIKit
3
+
4
+ struct Toast {
5
+ private(set) var id: String = UUID().uuidString
6
+ var symbol: String
7
+ var symbolFont: Font
8
+ var symbolForegroundStyle: (Color, Color)
9
+ var title: String
10
+ var message: String
11
+ /// Optional custom icon image, resolved from an ImageSourcePropType URI.
12
+ /// When present, overrides the SF Symbol.
13
+ var customIcon: UIImage?
14
+ /// Explicit accent override — when set, drives both the icon fill and the
15
+ /// pill's accent stroke, bypassing the symbol-derived default.
16
+ var accentOverride: Color?
17
+ /// Explicit stroke color — when set, bypasses the backdrop sampler and
18
+ /// paints a fixed outline.
19
+ var strokeOverride: Color?
20
+ /// Skip the backdrop luminance sampler. The outline falls back to the
21
+ /// neutral gray stroke on any backdrop.
22
+ var disableBackdropSampling: Bool = false
23
+ /// Label for an optional trailing action button in the pill.
24
+ var actionLabel: String?
25
+
26
+ /// SF Symbol fill color — doubles as the pill's accent tint for the
27
+ /// Apple-style stroke we draw around the expanded pill in dark mode.
28
+ var accentColor: Color { accentOverride ?? symbolForegroundStyle.1 }
29
+ }
@@ -0,0 +1,392 @@
1
+ import SwiftUI
2
+ import Combine
3
+ import UIKit
4
+
5
+ // The bridge intentionally mirrors the JS payload shape, so the public
6
+ // native entrypoints are wider than SwiftLint's default preference.
7
+ // swiftlint:disable function_parameter_count
8
+ @objc public class ToastManager: NSObject {
9
+ private var overlayWindow: PassThroughWindow?
10
+ private var hostingController: CustomHostingView?
11
+ private var autoDismissTimer: Timer?
12
+ private var dismissCancellable: AnyCancellable?
13
+ private var tapCancellable: AnyCancellable?
14
+ private var actionCancellable: AnyCancellable?
15
+ // Guards against double-firing onDismiss when a programmatic dismiss
16
+ // also trips the Combine subscription on `isPresented`.
17
+ private var isDismissing = false
18
+ // Deferred so the status bar doesn't flash back in mid-collapse, and
19
+ // cancellable so a queued toast keeps it hidden across the handoff.
20
+ private var statusBarRestoreWorkItem: DispatchWorkItem?
21
+ private var imageLoadTask: URLSessionDataTask?
22
+
23
+ @objc public var onDismiss: (() -> Void)?
24
+ @objc public var onPress: (() -> Void)?
25
+ @objc public var onActionPress: (() -> Void)?
26
+
27
+ @objc public func show(
28
+ icon: String,
29
+ iconUri: String,
30
+ title: String,
31
+ message: String,
32
+ duration: Int,
33
+ autoDismiss: Bool,
34
+ enableSwipeDismiss: Bool,
35
+ useDynamicIsland: Bool,
36
+ accentColor: UIColor?,
37
+ strokeColor: UIColor?,
38
+ disableBackdropSampling: Bool,
39
+ actionLabel: String,
40
+ accessibilityAnnouncement: String
41
+ ) {
42
+ let isFirstShow = overlayWindow == nil
43
+ ensureOverlayWindow()
44
+
45
+ guard let overlayWindow else { return }
46
+
47
+ let (primary, secondary) = iconColors(for: icon)
48
+ let accent = accentColor.map { Color($0) }
49
+ let stroke = strokeColor.map { Color($0) }
50
+
51
+ let toast = Toast(
52
+ symbol: icon,
53
+ symbolFont: .system(size: 35),
54
+ symbolForegroundStyle: (primary, accent ?? secondary),
55
+ title: title,
56
+ message: message,
57
+ customIcon: nil,
58
+ accentOverride: accent,
59
+ strokeOverride: stroke,
60
+ disableBackdropSampling: disableBackdropSampling,
61
+ actionLabel: actionLabel.isEmpty ? nil : actionLabel
62
+ )
63
+
64
+ overlayWindow.toast = toast
65
+ overlayWindow.useDynamicIsland = useDynamicIsland
66
+ overlayWindow.wasTapped = false
67
+ overlayWindow.actionTapped = false
68
+ isDismissing = false
69
+
70
+ loadCustomIconIfNeeded(uri: iconUri)
71
+
72
+ let present = { [weak self] in
73
+ guard let self, let overlayWindow = self.overlayWindow else { return }
74
+ overlayWindow.isPresented = true
75
+ if !disableBackdropSampling {
76
+ overlayWindow.startBackdropSampling()
77
+ }
78
+ self.cancelStatusBarRestore()
79
+ self.hostingController?.isStatusBarHidden = true
80
+ overlayWindow.makeKey()
81
+
82
+ self.cancelTimer()
83
+ if autoDismiss && duration > 0 {
84
+ let interval = TimeInterval(duration) / 1000.0
85
+ self.autoDismissTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { [weak self] _ in
86
+ DispatchQueue.main.async {
87
+ self?.dismiss()
88
+ }
89
+ }
90
+ }
91
+
92
+ if !accessibilityAnnouncement.isEmpty {
93
+ UIAccessibility.post(notification: .announcement, argument: accessibilityAnnouncement)
94
+ }
95
+ }
96
+
97
+ if isFirstShow {
98
+ DispatchQueue.main.async(execute: present)
99
+ } else {
100
+ present()
101
+ }
102
+ }
103
+
104
+ @objc public func update(
105
+ icon: String,
106
+ iconUri: String,
107
+ title: String,
108
+ message: String,
109
+ duration: Int,
110
+ autoDismiss: Bool,
111
+ accentColor: UIColor?,
112
+ strokeColor: UIColor?,
113
+ disableBackdropSampling: Bool,
114
+ actionLabel: String
115
+ ) {
116
+ guard let overlayWindow, overlayWindow.isPresented else { return }
117
+
118
+ let (primary, secondary) = iconColors(for: icon)
119
+ let accent = accentColor.map { Color($0) }
120
+ let stroke = strokeColor.map { Color($0) }
121
+
122
+ // Carry the resolved customIcon forward; loadCustomIconIfNeeded
123
+ // swaps it if the URI changed.
124
+ let previous = overlayWindow.toast
125
+ overlayWindow.toast = Toast(
126
+ symbol: icon,
127
+ symbolFont: .system(size: 35),
128
+ symbolForegroundStyle: (primary, accent ?? secondary),
129
+ title: title,
130
+ message: message,
131
+ customIcon: previous?.customIcon,
132
+ accentOverride: accent,
133
+ strokeOverride: stroke,
134
+ disableBackdropSampling: disableBackdropSampling,
135
+ actionLabel: actionLabel.isEmpty ? nil : actionLabel
136
+ )
137
+
138
+ loadCustomIconIfNeeded(uri: iconUri)
139
+
140
+ if disableBackdropSampling {
141
+ overlayWindow.stopBackdropSampling()
142
+ } else if overlayWindow.isPresented {
143
+ overlayWindow.startBackdropSampling()
144
+ }
145
+
146
+ cancelTimer()
147
+ if autoDismiss && duration > 0 {
148
+ let interval = TimeInterval(duration) / 1000.0
149
+ autoDismissTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { [weak self] _ in
150
+ DispatchQueue.main.async {
151
+ self?.dismiss()
152
+ }
153
+ }
154
+ }
155
+ }
156
+
157
+ @objc public func dismiss() {
158
+ cancelTimer()
159
+
160
+ guard let overlayWindow, overlayWindow.isPresented, !isDismissing else { return }
161
+ isDismissing = true
162
+
163
+ overlayWindow.isPresented = false
164
+ overlayWindow.stopBackdropSampling()
165
+ imageLoadTask?.cancel()
166
+ imageLoadTask = nil
167
+ scheduleStatusBarRestore()
168
+
169
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { [weak self] in
170
+ self?.onDismiss?()
171
+ }
172
+ }
173
+
174
+ // MARK: - Overlay Window
175
+
176
+ private func ensureOverlayWindow() {
177
+ guard let windowScene = UIApplication.shared.connectedScenes
178
+ .compactMap({ $0 as? UIWindowScene })
179
+ .first(where: { $0.activationState == .foregroundActive }) else { return }
180
+
181
+ if let existing = windowScene.windows.first(where: { $0.tag == 1009 }) as? PassThroughWindow {
182
+ overlayWindow = existing
183
+ hostingController = existing.rootViewController as? CustomHostingView
184
+ } else {
185
+ let window = PassThroughWindow(windowScene: windowScene)
186
+ window.backgroundColor = .clear
187
+ window.isHidden = false
188
+ window.isUserInteractionEnabled = true
189
+ window.tag = 1009
190
+
191
+ let hosting = CustomHostingView(
192
+ rootView: PrettyToastView(window: window)
193
+ )
194
+ hosting.view.backgroundColor = .clear
195
+ window.rootViewController = hosting
196
+
197
+ overlayWindow = window
198
+ hostingController = hosting
199
+ }
200
+
201
+ observeDismiss()
202
+ observeTap()
203
+ observeAction()
204
+ }
205
+
206
+ // Catches swipe-dismissals that flip `isPresented` from outside dismiss().
207
+ private func observeDismiss() {
208
+ guard let overlayWindow else { return }
209
+
210
+ dismissCancellable = overlayWindow.$isPresented
211
+ .dropFirst()
212
+ .filter { !$0 }
213
+ .sink { [weak self] _ in
214
+ guard let self, !self.isDismissing else { return }
215
+ self.isDismissing = true
216
+ self.cancelTimer()
217
+ self.overlayWindow?.stopBackdropSampling()
218
+ self.scheduleStatusBarRestore()
219
+
220
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { [weak self] in
221
+ self?.onDismiss?()
222
+ }
223
+ }
224
+ }
225
+
226
+ private func observeTap() {
227
+ guard let overlayWindow else { return }
228
+
229
+ tapCancellable = overlayWindow.$wasTapped
230
+ .dropFirst()
231
+ .filter { $0 }
232
+ .sink { [weak self] _ in
233
+ guard let self else { return }
234
+ self.overlayWindow?.wasTapped = false
235
+ self.onPress?()
236
+ }
237
+ }
238
+
239
+ private func observeAction() {
240
+ guard let overlayWindow else { return }
241
+
242
+ actionCancellable = overlayWindow.$actionTapped
243
+ .dropFirst()
244
+ .filter { $0 }
245
+ .sink { [weak self] _ in
246
+ guard let self else { return }
247
+ self.overlayWindow?.actionTapped = false
248
+ self.onActionPress?()
249
+ }
250
+ }
251
+
252
+ // MARK: - Helpers
253
+
254
+ private func loadCustomIconIfNeeded(uri: String) {
255
+ imageLoadTask?.cancel()
256
+ imageLoadTask = nil
257
+
258
+ if uri.isEmpty {
259
+ overlayWindow?.toast?.customIcon = nil
260
+ return
261
+ }
262
+
263
+ if let image = imageFromDataURL(uri) {
264
+ if var currentToast = overlayWindow?.toast {
265
+ currentToast.customIcon = image
266
+ overlayWindow?.toast = currentToast
267
+ }
268
+ return
269
+ }
270
+
271
+ // file:// URIs load synchronously; remote URLs fall through below.
272
+ if let url = URL(string: uri),
273
+ url.isFileURL,
274
+ let image = UIImage(contentsOfFile: url.path) {
275
+ overlayWindow?.toast?.customIcon = image
276
+ // Reassign the whole struct so @Published fires.
277
+ if var currentToast = overlayWindow?.toast {
278
+ currentToast.customIcon = image
279
+ overlayWindow?.toast = currentToast
280
+ }
281
+ return
282
+ }
283
+
284
+ guard let url = URL(string: uri) else { return }
285
+
286
+ imageLoadTask = URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in
287
+ guard let self, let data, let image = UIImage(data: data) else { return }
288
+ DispatchQueue.main.async {
289
+ if var currentToast = self.overlayWindow?.toast {
290
+ currentToast.customIcon = image
291
+ self.overlayWindow?.toast = currentToast
292
+ }
293
+ }
294
+ }
295
+ imageLoadTask?.resume()
296
+ }
297
+
298
+ private func restoreKeyWindow() {
299
+ UIApplication.shared.connectedScenes
300
+ .compactMap { $0 as? UIWindowScene }
301
+ .flatMap { $0.windows }
302
+ .first { $0.tag != 1009 && !$0.isHidden }?
303
+ .makeKey()
304
+ }
305
+
306
+ private func cancelTimer() {
307
+ autoDismissTimer?.invalidate()
308
+ autoDismissTimer = nil
309
+ }
310
+
311
+ // 0.5s ≈ collapse animation (0.35s) + JS round-trip slack so a follow-up
312
+ // show() can cancel this and keep the status bar hidden. Key-window
313
+ // handoff is bundled in to prevent the status bar from fading in
314
+ // behind the shrinking pill.
315
+ private func scheduleStatusBarRestore() {
316
+ statusBarRestoreWorkItem?.cancel()
317
+ let work = DispatchWorkItem { [weak self] in
318
+ guard let self else { return }
319
+ self.hostingController?.isStatusBarHidden = false
320
+ self.restoreKeyWindow()
321
+ }
322
+ statusBarRestoreWorkItem = work
323
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: work)
324
+ }
325
+
326
+ private func cancelStatusBarRestore() {
327
+ statusBarRestoreWorkItem?.cancel()
328
+ statusBarRestoreWorkItem = nil
329
+ }
330
+
331
+ private func imageFromDataURL(_ uri: String) -> UIImage? {
332
+ guard uri.starts(with: "data:"),
333
+ let commaIndex = uri.firstIndex(of: ",") else {
334
+ return nil
335
+ }
336
+
337
+ let metadata = String(uri[..<commaIndex])
338
+ let encodedPayload = String(uri[uri.index(after: commaIndex)...])
339
+
340
+ if metadata.contains(";base64"),
341
+ let data = Data(base64Encoded: encodedPayload) {
342
+ return UIImage(data: data)
343
+ }
344
+
345
+ let payload = encodedPayload.removingPercentEncoding ?? encodedPayload
346
+ return UIImage(data: Data(payload.utf8))
347
+ }
348
+
349
+ deinit {
350
+ // deinit may run off-main; Timer/UIWindow teardown must happen on
351
+ // main, so hop over before breaking the retain cycle.
352
+ let window = overlayWindow
353
+ let dismissCancel = dismissCancellable
354
+ let tapCancel = tapCancellable
355
+ let actionCancel = actionCancellable
356
+ let timer = autoDismissTimer
357
+ let workItem = statusBarRestoreWorkItem
358
+ let loadTask = imageLoadTask
359
+ DispatchQueue.main.async {
360
+ timer?.invalidate()
361
+ workItem?.cancel()
362
+ dismissCancel?.cancel()
363
+ tapCancel?.cancel()
364
+ actionCancel?.cancel()
365
+ loadTask?.cancel()
366
+ window?.stopBackdropSampling()
367
+ // Break the window ↔ hosting controller ↔ PrettyToastView cycle
368
+ // so the window can actually deallocate.
369
+ window?.rootViewController = nil
370
+ window?.isHidden = true
371
+ }
372
+ }
373
+
374
+ private func iconColors(for symbol: String) -> (Color, Color) {
375
+ if symbol.contains("checkmark") {
376
+ return (.white, .green)
377
+ } else if symbol.contains("xmark") {
378
+ return (.white, .red)
379
+ } else if symbol.contains("exclamation") {
380
+ return (.white, .orange)
381
+ } else if symbol.contains("info") {
382
+ return (.white, .blue)
383
+ } else if symbol.contains("heart") {
384
+ return (.white, .pink)
385
+ } else if symbol.contains("arrow") {
386
+ return (.white, .blue)
387
+ } else {
388
+ return (.white, .gray)
389
+ }
390
+ }
391
+ }
392
+ // swiftlint:enable function_parameter_count