@capgo/capacitor-pretty-toast 8.1.17 → 8.1.19

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.
@@ -66,7 +66,13 @@ class ToastOverlay(activity: Activity) {
66
66
  var onPress: (() -> Unit)? = null
67
67
  var onActionPress: (() -> Unit)? = null
68
68
 
69
- private val imageLoader: ExecutorService = Executors.newSingleThreadExecutor { r ->
69
+ // A `var`, not a `val`: show() calls destroy() on this same instance (not
70
+ // a fresh one) whenever useDynamicIsland changes, which shuts this down.
71
+ // loadIcon() rebuilds it on demand so a later custom-icon toast on the
72
+ // same overlay doesn't hit a RejectedExecutionException on a dead pool.
73
+ private var imageLoader: ExecutorService = newImageLoader()
74
+
75
+ private fun newImageLoader(): ExecutorService = Executors.newSingleThreadExecutor { r ->
70
76
  Thread(r, "ToastIconLoader").apply { isDaemon = true }
71
77
  }
72
78
  // Bitmaps keyed by URI so a repeat show of the same custom icon paints
@@ -379,6 +385,9 @@ class ToastOverlay(activity: Activity) {
379
385
  private fun loadIcon(built: ToastViewFactory.Built, uri: String) {
380
386
  val targetW = built.icon.layoutParams.width.takeIf { it > 0 } ?: DEFAULT_ICON_TARGET_PX
381
387
  val targetH = built.icon.layoutParams.height.takeIf { it > 0 } ?: DEFAULT_ICON_TARGET_PX
388
+ if (imageLoader.isShutdown) {
389
+ imageLoader = newImageLoader()
390
+ }
382
391
  imageLoader.execute {
383
392
  val bitmap = try {
384
393
  when {
@@ -28,6 +28,17 @@ class CutoutMorphAnimator(
28
28
  private var cornerAnimator: ValueAnimator? = null
29
29
  private val cornerRadiiBuf = FloatArray(8)
30
30
 
31
+ /**
32
+ * Set for the duration of dismiss()'s own pill.animate() run. A tap or
33
+ * release arriving in that window would otherwise reach snapBack(),
34
+ * which cancels the very same ViewPropertyAnimator instance (Android
35
+ * caches one per view) — cancel() never runs withEndAction, so the
36
+ * dismiss's own state reset and onEnd() callback would be lost forever,
37
+ * leaving the toast overlay's isDismissing flag stuck and every later
38
+ * toast permanently queued behind it.
39
+ */
40
+ private var isDismissingAnim = false
41
+
31
42
  /**
32
43
  * Lower bound for the measured pill height — guards against racing
33
44
  * the layout pass, where `pill.height` can briefly read as 0.
@@ -76,6 +87,7 @@ class CutoutMorphAnimator(
76
87
  }
77
88
 
78
89
  override fun dismiss(onEnd: () -> Unit) {
90
+ isDismissingAnim = true
79
91
  onBeforeDismiss()
80
92
 
81
93
  val expandedWidth = pill.width.toFloat().coerceAtLeast(1f)
@@ -110,6 +122,7 @@ class CutoutMorphAnimator(
110
122
  .setDuration(MORPH_DURATION_MS)
111
123
  .setInterpolator(MORPH_EASING)
112
124
  .withEndAction {
125
+ isDismissingAnim = false
113
126
  resetPillToExpandedResting()
114
127
  onEnd()
115
128
  }
@@ -123,6 +136,11 @@ class CutoutMorphAnimator(
123
136
  }
124
137
 
125
138
  override fun snapBack() {
139
+ // A dismiss already in flight owns this animator; cancelling it here
140
+ // would strand it (see isDismissingAnim above) instead of letting it
141
+ // finish and report back to ToastOverlay.
142
+ if (isDismissingAnim) return
143
+
126
144
  // Release-without-dismiss during a morph drag: spring back to full
127
145
  // size and restore content opacity.
128
146
  pill.animate().cancel()
@@ -17,55 +17,56 @@ struct PrettyToastView: View {
17
17
  let scaleX: CGFloat = isExpanded ? 1 : (layout.compactWidth / layout.expandedWidth)
18
18
  let scaleY: CGFloat = isExpanded ? 1 : (layout.compactHeight / layout.expandedHeight)
19
19
 
20
- let swipeGesture = DragGesture(minimumDistance: 2).onEnded { value in
21
- if value.translation.height < -8 || value.predictedEndTranslation.height < -40 {
20
+ // A separate `.onTapGesture` alongside a low-threshold
21
+ // `.gesture(DragGesture())` on the same view is a classic SwiftUI
22
+ // trap: `minimumDistance: 2` makes the drag start recognizing on
23
+ // almost any real touch, taps included, and once it does, it took
24
+ // exclusive priority over the sibling tap gesture — so a plain
25
+ // tap never reached `wasTapped` and the toast could never be
26
+ // dismissed by tapping it. One gesture, one decision on release,
27
+ // fixes that: a short release is always a tap regardless of
28
+ // whether swipe-dismiss is enabled.
29
+ let dismissGesture = DragGesture(minimumDistance: 0).onEnded { value in
30
+ if window.enableSwipeDismiss && (value.translation.height < -8 || value.predictedEndTranslation.height < -40) {
22
31
  window.swipeDismissRequested = true
32
+ } else {
33
+ window.wasTapped = true
23
34
  }
24
35
  }
25
36
 
26
37
  ZStack {
27
- Group {
28
- let pill = toastBackground()
29
- .overlay {
30
- toastContent(layout)
31
- .frame(width: layout.expandedWidth, height: layout.expandedHeight)
32
- .scaleEffect(x: scaleX, y: scaleY)
33
- }
34
- .frame(
35
- width: isExpanded ? layout.expandedWidth : layout.compactWidth,
36
- height: isExpanded ? layout.expandedHeight : layout.compactHeight
37
- )
38
- .opacity(layout.hasDynamicIsland ? 1 : (isExpanded ? 1 : 0))
39
- .modifier(CapsuleOpacityModifier(
40
- haveDynamicIsland: layout.hasDynamicIsland,
41
- isExpanded: isExpanded
42
- ))
43
- .modifier(GeometryGroupModifier())
44
- .contentShape(Rectangle())
45
- .onTapGesture {
46
- window.wasTapped = true
38
+ toastBackground()
39
+ .overlay {
40
+ toastContent(layout)
41
+ .frame(width: layout.expandedWidth, height: layout.expandedHeight)
42
+ .scaleEffect(x: scaleX, y: scaleY)
43
+ }
44
+ .frame(
45
+ width: isExpanded ? layout.expandedWidth : layout.compactWidth,
46
+ height: isExpanded ? layout.expandedHeight : layout.compactHeight
47
+ )
48
+ .opacity(layout.hasDynamicIsland ? 1 : (isExpanded ? 1 : 0))
49
+ .modifier(CapsuleOpacityModifier(
50
+ haveDynamicIsland: layout.hasDynamicIsland,
51
+ isExpanded: isExpanded
52
+ ))
53
+ .modifier(GeometryGroupModifier())
54
+ .contentShape(Rectangle())
55
+ .gesture(dismissGesture)
56
+ .background(
57
+ GeometryReader { geo in
58
+ Color.clear.preference(
59
+ key: ToastFrameKey.self,
60
+ value: geo.frame(in: .named("overlayWindow"))
61
+ )
47
62
  }
48
- .background(
49
- GeometryReader { geo in
50
- Color.clear.preference(
51
- key: ToastFrameKey.self,
52
- value: geo.frame(in: .named("overlayWindow"))
53
- )
54
- }
55
- )
56
- .onPreferenceChange(ToastFrameKey.self) { frame in
57
- if window.toastHitFrame != frame {
58
- window.toastHitFrame = frame
59
- }
63
+ )
64
+ .onPreferenceChange(ToastFrameKey.self) { frame in
65
+ if window.toastHitFrame != frame {
66
+ window.toastHitFrame = frame
60
67
  }
61
-
62
- if window.enableSwipeDismiss {
63
- pill.gesture(swipeGesture)
64
- } else {
65
- pill
66
68
  }
67
- }
68
- .offset(y: layout.hasDynamicIsland ? (isExpanded ? layout.expandedTopOffset : layout.topOffset) : 0)
69
+ .offset(y: layout.hasDynamicIsland ? (isExpanded ? layout.expandedTopOffset : layout.topOffset) : 0)
69
70
  }
70
71
  .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
71
72
  .padding(.top, layout.hasDynamicIsland ? 0 : (isExpanded ? max(safeArea.top, 10) : 0))
@@ -84,11 +84,18 @@ import UIKit
84
84
  self.cancelTimer()
85
85
  if autoDismiss && duration > 0 {
86
86
  let interval = TimeInterval(duration) / 1000.0
87
- self.autoDismissTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { [weak self] _ in
87
+ // `.common` mode, like the backdrop sampler below: `.default`-mode
88
+ // timers are suspended for as long as the run loop is in `.tracking`
89
+ // mode, which UIKit enters for any active touch/scroll — so a toast
90
+ // triggered while (or dismissed while) the user is scrolling would
91
+ // never time out, jamming every later toast behind it in the queue.
92
+ let timer = Timer(timeInterval: interval, repeats: false) { [weak self] _ in
88
93
  DispatchQueue.main.async {
89
94
  self?.dismiss()
90
95
  }
91
96
  }
97
+ RunLoop.main.add(timer, forMode: .common)
98
+ self.autoDismissTimer = timer
92
99
  }
93
100
 
94
101
  if !accessibilityAnnouncement.isEmpty {
@@ -148,11 +155,15 @@ import UIKit
148
155
  cancelTimer()
149
156
  if autoDismiss && duration > 0 {
150
157
  let interval = TimeInterval(duration) / 1000.0
151
- autoDismissTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { [weak self] _ in
158
+ // See the matching comment in show(): `.common` mode so scrolling
159
+ // doesn't suspend the countdown.
160
+ let timer = Timer(timeInterval: interval, repeats: false) { [weak self] _ in
152
161
  DispatchQueue.main.async {
153
162
  self?.dismiss()
154
163
  }
155
164
  }
165
+ RunLoop.main.add(timer, forMode: .common)
166
+ autoDismissTimer = timer
156
167
  }
157
168
  }
158
169
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capgo/capacitor-pretty-toast",
3
- "version": "8.1.17",
3
+ "version": "8.1.19",
4
4
  "description": "Native-first pretty toast notifications for Capacitor and the web",
5
5
  "main": "dist/plugin.cjs.js",
6
6
  "module": "dist/esm/index.js",
@@ -54,6 +54,7 @@
54
54
  "watch": "tsc --watch",
55
55
  "prepublishOnly": "bun run build",
56
56
  "check:wiring": "node scripts/check-capacitor-plugin-wiring.mjs",
57
+ "check:cap9-deprecated": "node scripts/check-cap9-deprecated.mjs",
57
58
  "example:install": "cd example-app && bun install --frozen-lockfile",
58
59
  "example:build": "bun run build && cd example-app && bun install --frozen-lockfile && bun run build",
59
60
  "changelog:ai": "node scripts/generate-ai-changelog.mjs"
@@ -0,0 +1,304 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Capacitor 9 deprecated native API guard.
4
+ *
5
+ * Fails when plugin native sources still use APIs removed in Capacitor 9.
6
+ * Does not flag Cordova SwiftPM product dependencies (still required on Cap 8).
7
+ *
8
+ * Usage:
9
+ * node scripts/check-cap9-deprecated.mjs
10
+ * node scripts/check-cap9-deprecated.mjs --workspace
11
+ * node scripts/check-cap9-deprecated.mjs --dir path
12
+ */
13
+
14
+ import fs from "node:fs";
15
+ import path from "node:path";
16
+ import { fileURLToPath } from "node:url";
17
+
18
+ const SKIP_DIRS = new Set([
19
+ "node_modules",
20
+ "dist",
21
+ "build",
22
+ ".build",
23
+ ".gradle",
24
+ "Pods",
25
+ "DerivedData",
26
+ ".swiftpm",
27
+ ".git",
28
+ "example-app",
29
+ ]);
30
+
31
+ /** @type {{ id: string, pattern: RegExp, exts: string[], ignoreLine?: RegExp }[]} */
32
+ const RULES = [
33
+ {
34
+ id: "hasOption",
35
+ pattern: /\bhasOption\s*\(/,
36
+ exts: [".java", ".kt", ".swift"],
37
+ },
38
+ {
39
+ id: "getConfigValue",
40
+ pattern: /\bgetConfigValue\s*\(/,
41
+ exts: [".java", ".kt", ".swift"],
42
+ },
43
+ {
44
+ id: "@NativePlugin",
45
+ pattern: /@NativePlugin\b/,
46
+ exts: [".java", ".kt"],
47
+ },
48
+ {
49
+ id: "saveCall",
50
+ pattern: /\bsaveCall\s*\(/,
51
+ exts: [".java", ".kt", ".swift"],
52
+ },
53
+ {
54
+ id: "getSavedCall",
55
+ pattern: /\bgetSavedCall\s*\(/,
56
+ exts: [".java", ".kt", ".swift"],
57
+ },
58
+ {
59
+ id: "freeSavedCall",
60
+ pattern: /\bfreeSavedCall\s*\(/,
61
+ exts: [".java", ".kt", ".swift"],
62
+ },
63
+ {
64
+ id: "releaseCall",
65
+ pattern: /\breleaseCall\s*\(/,
66
+ exts: [".java", ".kt", ".swift"],
67
+ ignoreLine: /\.releaseCall\s*\(\s*withID:/,
68
+ },
69
+ {
70
+ id: "pluginRequestPermission",
71
+ pattern: /\bpluginRequestPermissions?\s*\(/,
72
+ exts: [".java", ".kt"],
73
+ },
74
+ {
75
+ id: "pluginRequestAllPermissions",
76
+ pattern: /\bpluginRequestAllPermissions\s*\(/,
77
+ exts: [".java", ".kt"],
78
+ },
79
+ {
80
+ id: "hasDefinedPermissions",
81
+ pattern: /\bhasDefinedPermissions\s*\(/,
82
+ exts: [".java", ".kt"],
83
+ },
84
+ {
85
+ id: "CAPBridge",
86
+ pattern: /\bCAPBridge\./,
87
+ exts: [".swift"],
88
+ ignoreLine: /CAPBridgedPlugin/,
89
+ },
90
+ {
91
+ id: "CAPNotifications",
92
+ pattern: /\bCAPNotifications\b/,
93
+ exts: [".swift"],
94
+ },
95
+ ];
96
+
97
+ const CORDova_SPM_LINE =
98
+ /\.product\s*\(\s*name\s*:\s*"Cordova"\s*,\s*package\s*:\s*"capacitor-swift-pm"\s*\)/;
99
+
100
+ function readText(p) {
101
+ try {
102
+ return fs.readFileSync(p, "utf8");
103
+ } catch {
104
+ return "";
105
+ }
106
+ }
107
+
108
+ function exists(p) {
109
+ try {
110
+ fs.accessSync(p);
111
+ return true;
112
+ } catch {
113
+ return false;
114
+ }
115
+ }
116
+
117
+ function parseArgs(argv) {
118
+ const out = { dir: process.cwd(), workspace: false };
119
+ for (let i = 2; i < argv.length; i++) {
120
+ const a = argv[i];
121
+ if (a === "--workspace") {
122
+ out.workspace = true;
123
+ continue;
124
+ }
125
+ if (a === "--dir" || a === "--pluginDir") {
126
+ out.dir = path.resolve(argv[++i] || ".");
127
+ continue;
128
+ }
129
+ }
130
+ return out;
131
+ }
132
+
133
+ function walkFiles(rootDir, exts) {
134
+ const out = [];
135
+ const stack = [rootDir];
136
+ while (stack.length) {
137
+ const dir = stack.pop();
138
+ let entries;
139
+ try {
140
+ entries = fs.readdirSync(dir, { withFileTypes: true });
141
+ } catch {
142
+ continue;
143
+ }
144
+ for (const e of entries) {
145
+ if (e.isDirectory()) {
146
+ if (SKIP_DIRS.has(e.name)) continue;
147
+ stack.push(path.join(dir, e.name));
148
+ continue;
149
+ }
150
+ if (!e.isFile()) continue;
151
+ for (const ext of exts) {
152
+ if (e.name.endsWith(ext)) {
153
+ out.push(path.join(dir, e.name));
154
+ break;
155
+ }
156
+ }
157
+ }
158
+ }
159
+ out.sort();
160
+ return out;
161
+ }
162
+
163
+ function collectScanRoots(pluginDir, cap) {
164
+ const roots = [];
165
+ if (cap.android) {
166
+ const androidMain = path.join(pluginDir, "android", "src", "main");
167
+ if (exists(androidMain)) roots.push(androidMain);
168
+ }
169
+ if (cap.ios) {
170
+ const iosSources = path.join(pluginDir, "ios", "Sources");
171
+ if (exists(iosSources)) roots.push(iosSources);
172
+ else {
173
+ const iosDir = path.join(pluginDir, "ios");
174
+ if (exists(iosDir)) roots.push(iosDir);
175
+ }
176
+ }
177
+ const packageSwift = path.join(pluginDir, "Package.swift");
178
+ if (exists(packageSwift)) roots.push(packageSwift);
179
+ return roots;
180
+ }
181
+
182
+ function scanFile(filePath, rule) {
183
+ const ext = path.extname(filePath);
184
+ if (!rule.exts.includes(ext)) return [];
185
+
186
+ const txt = readText(filePath);
187
+ const lines = txt.split(/\r?\n/);
188
+ const hits = [];
189
+ for (let i = 0; i < lines.length; i++) {
190
+ const line = lines[i];
191
+ if (filePath.endsWith("Package.swift") && CORDova_SPM_LINE.test(line)) {
192
+ continue;
193
+ }
194
+ if (rule.ignoreLine?.test(line)) continue;
195
+ if (rule.pattern.test(line)) {
196
+ hits.push({ line: i + 1, text: line.trim() });
197
+ }
198
+ }
199
+ return hits;
200
+ }
201
+
202
+ function listWorkspacePluginDirs(repoRoot) {
203
+ const packagesRoot = path.join(repoRoot, "packages");
204
+ if (!exists(packagesRoot)) return [];
205
+ return fs
206
+ .readdirSync(packagesRoot, { withFileTypes: true })
207
+ .filter((e) => e.isDirectory())
208
+ .map((e) => path.join(packagesRoot, e.name))
209
+ .filter((dir) => {
210
+ const pkgPath = path.join(dir, "package.json");
211
+ if (!exists(pkgPath)) return false;
212
+ try {
213
+ const pkg = JSON.parse(readText(pkgPath));
214
+ const cap = typeof pkg.capacitor === "object" && pkg.capacitor ? pkg.capacitor : {};
215
+ return Boolean(cap.android || cap.ios);
216
+ } catch {
217
+ return false;
218
+ }
219
+ })
220
+ .sort();
221
+ }
222
+
223
+ /**
224
+ * @returns {boolean} true when the plugin passes
225
+ */
226
+ function checkPluginDir(pluginDir) {
227
+ const pkgPath = path.join(pluginDir, "package.json");
228
+
229
+ if (!exists(pkgPath)) {
230
+ console.error(`[cap9-deprecated] ERROR: missing package.json in ${pluginDir}`);
231
+ return false;
232
+ }
233
+
234
+ let pkg;
235
+ try {
236
+ pkg = JSON.parse(readText(pkgPath));
237
+ } catch (e) {
238
+ console.error(`[cap9-deprecated] ERROR: invalid package.json (${pkgPath}): ${e?.message || e}`);
239
+ return false;
240
+ }
241
+
242
+ const cap = typeof pkg.capacitor === "object" && pkg.capacitor ? pkg.capacitor : {};
243
+ if (!cap.android && !cap.ios) {
244
+ return true;
245
+ }
246
+
247
+ const scanRoots = collectScanRoots(pluginDir, cap);
248
+ const allExts = [...new Set(RULES.flatMap((r) => r.exts))];
249
+ const files = [];
250
+ for (const root of scanRoots) {
251
+ if (root.endsWith("Package.swift")) {
252
+ files.push(root);
253
+ continue;
254
+ }
255
+ files.push(...walkFiles(root, allExts));
256
+ }
257
+
258
+ const violations = [];
259
+ for (const file of files) {
260
+ for (const rule of RULES) {
261
+ const hits = scanFile(file, rule);
262
+ for (const hit of hits) {
263
+ violations.push({
264
+ rule: rule.id,
265
+ file: path.relative(pluginDir, file),
266
+ line: hit.line,
267
+ text: hit.text,
268
+ });
269
+ }
270
+ }
271
+ }
272
+
273
+ if (violations.length) {
274
+ const relDir = path.relative(process.cwd(), pluginDir) || ".";
275
+ console.error(`[cap9-deprecated] FAIL in ${relDir}`);
276
+ for (const v of violations) {
277
+ console.error(`- ${v.rule}: ${v.file}:${v.line}: ${v.text}`);
278
+ }
279
+ return false;
280
+ }
281
+
282
+ return true;
283
+ }
284
+
285
+ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
286
+ const args = parseArgs(process.argv);
287
+
288
+ if (args.workspace) {
289
+ const pluginDirs = listWorkspacePluginDirs(repoRoot);
290
+ if (!pluginDirs.length) {
291
+ console.error("[cap9-deprecated] ERROR: no Capacitor plugin packages found under packages/*");
292
+ process.exit(2);
293
+ }
294
+ let failed = false;
295
+ for (const dir of pluginDirs) {
296
+ if (!checkPluginDir(dir)) failed = true;
297
+ }
298
+ if (failed) process.exit(1);
299
+ console.log(`[cap9-deprecated] OK (${pluginDirs.length} plugin package(s) scanned)`);
300
+ process.exit(0);
301
+ }
302
+
303
+ const ok = checkPluginDir(args.dir);
304
+ process.exit(ok ? 0 : 1);