@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.
- package/CapgoCapacitorPrettyToast.podspec +17 -0
- package/LICENSE +373 -0
- package/Package.swift +28 -0
- package/README.md +341 -0
- package/android/build.gradle +71 -0
- package/android/src/main/AndroidManifest.xml +6 -0
- package/android/src/main/java/com/toast/PrettyToastPlugin.kt +197 -0
- package/android/src/main/java/com/toast/ToastOverlay.kt +495 -0
- package/android/src/main/java/com/toast/anim/CutoutMorphAnimator.kt +235 -0
- package/android/src/main/java/com/toast/anim/SlideAnimator.kt +64 -0
- package/android/src/main/java/com/toast/anim/ToastAnimator.kt +23 -0
- package/android/src/main/java/com/toast/backdrop/BackdropSampler.kt +142 -0
- package/android/src/main/java/com/toast/backdrop/OutlineController.kt +100 -0
- package/android/src/main/java/com/toast/cutout/CutoutDetector.kt +88 -0
- package/android/src/main/java/com/toast/cutout/CutoutInfo.kt +28 -0
- package/android/src/main/java/com/toast/gesture/ToastGestureHandler.kt +68 -0
- package/android/src/main/java/com/toast/ui/IconMapper.kt +26 -0
- package/android/src/main/java/com/toast/ui/PassThroughFrameLayout.kt +53 -0
- package/android/src/main/java/com/toast/ui/ToastViewFactory.kt +224 -0
- package/android/src/main/java/com/toast/util/Density.kt +17 -0
- package/android/src/main/java/com/toast/util/StatusBarController.kt +24 -0
- package/android/src/main/java/com/toast/util/ToastConstants.kt +36 -0
- package/android/src/main/res/.gitkeep +0 -0
- package/android/src/main/res/drawable/ic_arrow_downward.xml +9 -0
- package/android/src/main/res/drawable/ic_arrow_upward.xml +9 -0
- package/android/src/main/res/drawable/ic_cancel.xml +9 -0
- package/android/src/main/res/drawable/ic_check_circle.xml +9 -0
- package/android/src/main/res/drawable/ic_favorite.xml +9 -0
- package/android/src/main/res/drawable/ic_info.xml +9 -0
- package/android/src/main/res/drawable/ic_mail.xml +9 -0
- package/android/src/main/res/drawable/ic_notifications.xml +9 -0
- package/android/src/main/res/drawable/ic_touch_app.xml +9 -0
- package/android/src/main/res/drawable/ic_warning.xml +9 -0
- package/android/src/main/res/drawable/ic_wifi.xml +9 -0
- package/android/src/main/res/values/colors.xml +3 -0
- package/android/src/main/res/values/strings.xml +3 -0
- package/android/src/main/res/values/styles.xml +3 -0
- package/android/src/test/java/com/toast/PrettyToastPluginTest.kt +26 -0
- package/dist/docs.json +459 -0
- package/dist/esm/controller.d.ts +30 -0
- package/dist/esm/controller.js +271 -0
- package/dist/esm/controller.js.map +1 -0
- package/dist/esm/definitions.d.ts +144 -0
- package/dist/esm/definitions.js +2 -0
- package/dist/esm/definitions.js.map +1 -0
- package/dist/esm/driver.d.ts +19 -0
- package/dist/esm/driver.js +24 -0
- package/dist/esm/driver.js.map +1 -0
- package/dist/esm/icons.d.ts +14 -0
- package/dist/esm/icons.js +138 -0
- package/dist/esm/icons.js.map +1 -0
- package/dist/esm/index.d.ts +2 -0
- package/dist/esm/index.js +2 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/internal-plugin.d.ts +2 -0
- package/dist/esm/internal-plugin.js +5 -0
- package/dist/esm/internal-plugin.js.map +1 -0
- package/dist/esm/internal-types.d.ts +31 -0
- package/dist/esm/internal-types.js +2 -0
- package/dist/esm/internal-types.js.map +1 -0
- package/dist/esm/toast.d.ts +1 -0
- package/dist/esm/toast.js +5 -0
- package/dist/esm/toast.js.map +1 -0
- package/dist/esm/web-renderer.d.ts +36 -0
- package/dist/esm/web-renderer.js +296 -0
- package/dist/esm/web-renderer.js.map +1 -0
- package/dist/esm/web.d.ts +10 -0
- package/dist/esm/web.js +28 -0
- package/dist/esm/web.js.map +1 -0
- package/dist/plugin.cjs.js +770 -0
- package/dist/plugin.cjs.js.map +1 -0
- package/dist/plugin.js +773 -0
- package/dist/plugin.js.map +1 -0
- package/ios/Sources/PrettyToastPlugin/CustomHostingView.swift +13 -0
- package/ios/Sources/PrettyToastPlugin/PassThroughWindow.swift +143 -0
- package/ios/Sources/PrettyToastPlugin/PrettyToastColorParser.swift +94 -0
- package/ios/Sources/PrettyToastPlugin/PrettyToastPlugin.swift +138 -0
- package/ios/Sources/PrettyToastPlugin/PrettyToastView.swift +267 -0
- package/ios/Sources/PrettyToastPlugin/Toast.swift +29 -0
- package/ios/Sources/PrettyToastPlugin/ToastManager.swift +392 -0
- package/ios/Tests/PrettyToastPluginTests/PrettyToastPluginTests.swift +21 -0
- package/package.json +98 -0
- package/scripts/check-capacitor-plugin-wiring.mjs +254 -0
- package/scripts/deploy-example-capgo.mjs +86 -0
- 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
|