@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,495 @@
|
|
|
1
|
+
package com.toast
|
|
2
|
+
|
|
3
|
+
import android.app.Activity
|
|
4
|
+
import android.graphics.Bitmap
|
|
5
|
+
import android.graphics.BitmapFactory
|
|
6
|
+
import android.os.Handler
|
|
7
|
+
import android.os.Looper
|
|
8
|
+
import android.util.Base64
|
|
9
|
+
import android.util.LruCache
|
|
10
|
+
import android.view.View
|
|
11
|
+
import android.view.ViewGroup
|
|
12
|
+
import java.lang.ref.WeakReference
|
|
13
|
+
import java.net.URL
|
|
14
|
+
import java.util.concurrent.ExecutorService
|
|
15
|
+
import java.util.concurrent.Executors
|
|
16
|
+
import com.toast.anim.CutoutMorphAnimator
|
|
17
|
+
import com.toast.anim.SlideAnimator
|
|
18
|
+
import com.toast.anim.ToastAnimator
|
|
19
|
+
import com.toast.backdrop.BackdropSampler
|
|
20
|
+
import com.toast.backdrop.OutlineController
|
|
21
|
+
import com.toast.cutout.CutoutDetector
|
|
22
|
+
import com.toast.gesture.ToastGestureHandler
|
|
23
|
+
import com.toast.ui.IconMapper
|
|
24
|
+
import com.toast.ui.ToastViewFactory
|
|
25
|
+
import com.toast.util.Density
|
|
26
|
+
import com.toast.util.StatusBarController
|
|
27
|
+
import com.toast.util.ToastConstants.DISMISS_CALLBACK_BUFFER_MS
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Thin orchestrator over the toast subsystems:
|
|
31
|
+
* - [CutoutDetector] reads device geometry into a snapshot
|
|
32
|
+
* - [ToastViewFactory] builds the view hierarchy from that snapshot
|
|
33
|
+
* - A [ToastAnimator] (cutout-morph or slide) drives show/dismiss/drag
|
|
34
|
+
* - [ToastGestureHandler] wires touches into the animator
|
|
35
|
+
*
|
|
36
|
+
* Owns only lifecycle/state: show flags, auto-dismiss timer, and the
|
|
37
|
+
* useDynamicIsland-changed recreate rule.
|
|
38
|
+
*/
|
|
39
|
+
class ToastOverlay(activity: Activity) {
|
|
40
|
+
|
|
41
|
+
// Hold the hosting Activity weakly so that if it's destroyed (rotation,
|
|
42
|
+
// finish, process trim) while the overlay still has state lying around,
|
|
43
|
+
// we don't keep it alive — we just bail out of any operation that needs it.
|
|
44
|
+
private val activityRef = WeakReference(activity)
|
|
45
|
+
private val activity: Activity?
|
|
46
|
+
get() = activityRef.get()
|
|
47
|
+
|
|
48
|
+
private var views: ToastViewFactory.Built? = null
|
|
49
|
+
private var animator: ToastAnimator? = null
|
|
50
|
+
private var cutoutAnimator: CutoutMorphAnimator? = null
|
|
51
|
+
private var isCutoutMorph = false
|
|
52
|
+
private var backdropSampler: BackdropSampler? = null
|
|
53
|
+
private var outline: OutlineController? = null
|
|
54
|
+
|
|
55
|
+
private val handler = Handler(Looper.getMainLooper())
|
|
56
|
+
private var dismissRunnable: Runnable? = null
|
|
57
|
+
// Pending status-bar restore. Scheduled after the collapse animation so
|
|
58
|
+
// the bar doesn't flicker between queued toasts. Cancelled by the next
|
|
59
|
+
// show() if one arrives before it fires.
|
|
60
|
+
private var statusBarRestoreRunnable: Runnable? = null
|
|
61
|
+
private var isShowing = false
|
|
62
|
+
private var isDismissing = false
|
|
63
|
+
private var useDynamicIslandProp = true
|
|
64
|
+
|
|
65
|
+
var onDismiss: (() -> Unit)? = null
|
|
66
|
+
var onPress: (() -> Unit)? = null
|
|
67
|
+
var onActionPress: (() -> Unit)? = null
|
|
68
|
+
|
|
69
|
+
private val imageLoader: ExecutorService = Executors.newSingleThreadExecutor { r ->
|
|
70
|
+
Thread(r, "ToastIconLoader").apply { isDaemon = true }
|
|
71
|
+
}
|
|
72
|
+
// Bitmaps keyed by URI so a repeat show of the same custom icon paints
|
|
73
|
+
// synchronously — no flash of the default drawable, no re-fetch.
|
|
74
|
+
private val iconCache = LruCache<String, Bitmap>(8)
|
|
75
|
+
// Tracks the most recently requested URI. Async loads that come back
|
|
76
|
+
// after a newer request fires are dropped (late response, stale data).
|
|
77
|
+
private var currentImageUri: String = ""
|
|
78
|
+
|
|
79
|
+
fun show(
|
|
80
|
+
icon: String,
|
|
81
|
+
iconUri: String,
|
|
82
|
+
title: String,
|
|
83
|
+
message: String,
|
|
84
|
+
duration: Int,
|
|
85
|
+
autoDismiss: Boolean,
|
|
86
|
+
enableSwipeDismiss: Boolean,
|
|
87
|
+
useDynamicIsland: Boolean,
|
|
88
|
+
accentColor: Int?,
|
|
89
|
+
strokeColor: Int?,
|
|
90
|
+
disableBackdropSampling: Boolean,
|
|
91
|
+
actionLabel: String,
|
|
92
|
+
accessibilityAnnouncement: String,
|
|
93
|
+
) {
|
|
94
|
+
if (useDynamicIsland != this.useDynamicIslandProp) {
|
|
95
|
+
this.useDynamicIslandProp = useDynamicIsland
|
|
96
|
+
destroy()
|
|
97
|
+
}
|
|
98
|
+
this.useDynamicIslandProp = useDynamicIsland
|
|
99
|
+
|
|
100
|
+
if (isDismissing) {
|
|
101
|
+
handler.postDelayed({
|
|
102
|
+
show(icon, iconUri, title, message, duration, autoDismiss, enableSwipeDismiss, useDynamicIsland, accentColor, strokeColor, disableBackdropSampling, actionLabel, accessibilityAnnouncement)
|
|
103
|
+
}, 50)
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
cancelAutoDismiss()
|
|
108
|
+
isDismissing = false
|
|
109
|
+
|
|
110
|
+
val built = ensureOverlay() ?: return
|
|
111
|
+
val currentAnimator = animator ?: return
|
|
112
|
+
|
|
113
|
+
val tint = updateContent(built, icon, iconUri, title, message, accentColor)
|
|
114
|
+
bindAction(built, actionLabel, tint)
|
|
115
|
+
outline?.setOverride(strokeColor)
|
|
116
|
+
installGestures(built, currentAnimator, enableSwipeDismiss)
|
|
117
|
+
|
|
118
|
+
if (!isShowing) {
|
|
119
|
+
isShowing = true
|
|
120
|
+
built.container.visibility = View.VISIBLE
|
|
121
|
+
|
|
122
|
+
built.pill.animate().cancel()
|
|
123
|
+
built.content.animate().cancel()
|
|
124
|
+
built.pill.translationY = 0f
|
|
125
|
+
built.pill.scaleX = 1f
|
|
126
|
+
built.pill.scaleY = 1f
|
|
127
|
+
built.pill.alpha = 1f
|
|
128
|
+
built.content.alpha = 1f
|
|
129
|
+
|
|
130
|
+
currentAnimator.show()
|
|
131
|
+
if (!disableBackdropSampling) {
|
|
132
|
+
startBackdropSampling()
|
|
133
|
+
} else {
|
|
134
|
+
stopBackdropSampling()
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (accessibilityAnnouncement.isNotEmpty()) {
|
|
138
|
+
built.pill.announceForAccessibility(accessibilityAnnouncement)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (autoDismiss && duration > 0) {
|
|
143
|
+
dismissRunnable = Runnable { dismiss() }
|
|
144
|
+
handler.postDelayed(dismissRunnable!!, duration.toLong())
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Mutates the currently presented toast in place. Updates content on the
|
|
150
|
+
* live view hierarchy and restarts the auto-dismiss timer — does NOT
|
|
151
|
+
* re-run the expand animation.
|
|
152
|
+
*/
|
|
153
|
+
fun update(
|
|
154
|
+
icon: String,
|
|
155
|
+
iconUri: String,
|
|
156
|
+
title: String,
|
|
157
|
+
message: String,
|
|
158
|
+
duration: Int,
|
|
159
|
+
autoDismiss: Boolean,
|
|
160
|
+
accentColor: Int?,
|
|
161
|
+
strokeColor: Int?,
|
|
162
|
+
disableBackdropSampling: Boolean,
|
|
163
|
+
actionLabel: String,
|
|
164
|
+
) {
|
|
165
|
+
val built = views ?: return
|
|
166
|
+
if (!isShowing || isDismissing) return
|
|
167
|
+
|
|
168
|
+
val tint = updateContent(built, icon, iconUri, title, message, accentColor)
|
|
169
|
+
bindAction(built, actionLabel, tint)
|
|
170
|
+
outline?.setOverride(strokeColor)
|
|
171
|
+
|
|
172
|
+
if (disableBackdropSampling) {
|
|
173
|
+
stopBackdropSampling()
|
|
174
|
+
} else if (backdropSampler == null) {
|
|
175
|
+
startBackdropSampling()
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
cancelAutoDismiss()
|
|
179
|
+
if (autoDismiss && duration > 0) {
|
|
180
|
+
dismissRunnable = Runnable { dismiss() }
|
|
181
|
+
handler.postDelayed(dismissRunnable!!, duration.toLong())
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
fun dismiss() {
|
|
186
|
+
if (!isShowing || isDismissing) return
|
|
187
|
+
isDismissing = true
|
|
188
|
+
cancelAutoDismiss()
|
|
189
|
+
|
|
190
|
+
// Stop sampling + freeze any in-flight stroke crossfade before we
|
|
191
|
+
// hand off to the animator. Runs on every dismiss path — including
|
|
192
|
+
// the `animator == null` early return below — so we never leave a
|
|
193
|
+
// tick runnable or a ValueAnimator running against a view that's
|
|
194
|
+
// about to go away.
|
|
195
|
+
stopBackdropSampling()
|
|
196
|
+
outline?.cancel()
|
|
197
|
+
|
|
198
|
+
val currentAnimator = animator ?: run {
|
|
199
|
+
isShowing = false
|
|
200
|
+
isDismissing = false
|
|
201
|
+
onDismiss?.invoke()
|
|
202
|
+
return
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
currentAnimator.dismiss {
|
|
206
|
+
isShowing = false
|
|
207
|
+
isDismissing = false
|
|
208
|
+
views?.container?.visibility = View.GONE
|
|
209
|
+
if (isCutoutMorph) {
|
|
210
|
+
scheduleStatusBarRestore()
|
|
211
|
+
// Match iOS's 50ms buffer between morph end and onDismiss callback.
|
|
212
|
+
handler.postDelayed({ onDismiss?.invoke() }, DISMISS_CALLBACK_BUFFER_MS)
|
|
213
|
+
} else {
|
|
214
|
+
onDismiss?.invoke()
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
fun destroy() {
|
|
220
|
+
cancelAutoDismiss()
|
|
221
|
+
cancelStatusBarRestore()
|
|
222
|
+
stopBackdropSampling()
|
|
223
|
+
outline?.cancel()
|
|
224
|
+
outline = null
|
|
225
|
+
handler.removeCallbacksAndMessages(null)
|
|
226
|
+
cutoutAnimator?.cancelPendingCallbacks()
|
|
227
|
+
|
|
228
|
+
val decorView = activity?.window?.decorView as? ViewGroup
|
|
229
|
+
views?.container?.let { decorView?.removeView(it) }
|
|
230
|
+
|
|
231
|
+
imageLoader.shutdownNow()
|
|
232
|
+
iconCache.evictAll()
|
|
233
|
+
|
|
234
|
+
views = null
|
|
235
|
+
animator = null
|
|
236
|
+
cutoutAnimator = null
|
|
237
|
+
isShowing = false
|
|
238
|
+
isDismissing = false
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
private fun ensureOverlay(): ToastViewFactory.Built? {
|
|
242
|
+
views?.let { return it }
|
|
243
|
+
|
|
244
|
+
val activity = this.activity ?: return null
|
|
245
|
+
val decorView = activity.window?.decorView as? ViewGroup ?: return null
|
|
246
|
+
val density = Density.from(activity.resources)
|
|
247
|
+
|
|
248
|
+
val info = CutoutDetector.detect(activity, decorView, useDynamicIsland = useDynamicIslandProp)
|
|
249
|
+
val shouldUseMorph = useDynamicIslandProp
|
|
250
|
+
isCutoutMorph = shouldUseMorph
|
|
251
|
+
|
|
252
|
+
val factory = ToastViewFactory(activity, density)
|
|
253
|
+
val built = factory.build(info)
|
|
254
|
+
decorView.addView(built.container)
|
|
255
|
+
|
|
256
|
+
animator = if (shouldUseMorph) {
|
|
257
|
+
CutoutMorphAnimator(
|
|
258
|
+
pill = built.pill,
|
|
259
|
+
content = built.content,
|
|
260
|
+
info = info,
|
|
261
|
+
expandedCornerRadius = built.expandedCornerRadius,
|
|
262
|
+
density = density,
|
|
263
|
+
onBeforeShow = {
|
|
264
|
+
cancelStatusBarRestore()
|
|
265
|
+
this.activity?.let { StatusBarController.hide(it) }
|
|
266
|
+
},
|
|
267
|
+
).also { cutoutAnimator = it }
|
|
268
|
+
} else {
|
|
269
|
+
SlideAnimator(built.pill, density)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
outline = OutlineController(
|
|
273
|
+
pillBackground = built.pillBackground,
|
|
274
|
+
strokeWidthPx = built.strokeWidthPx,
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
views = built
|
|
278
|
+
return built
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// MARK: - Backdrop sampling
|
|
282
|
+
//
|
|
283
|
+
// Mirrors iOS's PassThroughWindow sampler: while the toast is on-screen,
|
|
284
|
+
// average the luminance of the top strip of the app's content view and
|
|
285
|
+
// flip the outline between the toast's accent colour and a faint neutral
|
|
286
|
+
// white. `OutlineController` handles the 300 ms ARGB crossfade between
|
|
287
|
+
// the two stroke colours so the change is a soft transition, not a pop.
|
|
288
|
+
|
|
289
|
+
private fun startBackdropSampling() {
|
|
290
|
+
val activity = this.activity ?: return
|
|
291
|
+
val density = Density.from(activity.resources)
|
|
292
|
+
stopBackdropSampling()
|
|
293
|
+
val sampler = BackdropSampler(
|
|
294
|
+
activity = activity,
|
|
295
|
+
density = density,
|
|
296
|
+
onTintChanged = { tint -> outline?.setTint(tint, animated = true) },
|
|
297
|
+
)
|
|
298
|
+
backdropSampler = sampler
|
|
299
|
+
sampler.start()
|
|
300
|
+
// Seed the stroke with the sampler's first reading (which it computed
|
|
301
|
+
// synchronously in start()) so we don't flash the default grey tint.
|
|
302
|
+
outline?.setTint(sampler.tint, animated = false)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
private fun stopBackdropSampling() {
|
|
306
|
+
backdropSampler?.stop()
|
|
307
|
+
backdropSampler = null
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
private fun updateContent(
|
|
311
|
+
built: ToastViewFactory.Built,
|
|
312
|
+
icon: String,
|
|
313
|
+
iconUri: String,
|
|
314
|
+
title: String,
|
|
315
|
+
message: String,
|
|
316
|
+
accentOverride: Int?,
|
|
317
|
+
): Int {
|
|
318
|
+
val (drawableRes, defaultTint) = IconMapper.map(icon)
|
|
319
|
+
val tint = accentOverride ?: defaultTint
|
|
320
|
+
// Accent always flows to the outline stroke, regardless of which
|
|
321
|
+
// icon path we take.
|
|
322
|
+
outline?.setAccent(tint)
|
|
323
|
+
|
|
324
|
+
currentImageUri = iconUri
|
|
325
|
+
if (iconUri.isNotEmpty()) {
|
|
326
|
+
val cached = iconCache[iconUri]
|
|
327
|
+
if (cached != null) {
|
|
328
|
+
// Repeat show — apply synchronously so there's no flash of the
|
|
329
|
+
// default drawable while we "re-load" something we already have.
|
|
330
|
+
built.icon.setImageBitmap(cached)
|
|
331
|
+
built.icon.colorFilter = null
|
|
332
|
+
} else {
|
|
333
|
+
// Clear the view so the previous toast's icon (or any default)
|
|
334
|
+
// doesn't hang around while we fetch. The ImageView keeps its
|
|
335
|
+
// frame, so layout doesn't jump.
|
|
336
|
+
built.icon.setImageDrawable(null)
|
|
337
|
+
built.icon.colorFilter = null
|
|
338
|
+
loadIcon(built, iconUri)
|
|
339
|
+
}
|
|
340
|
+
} else {
|
|
341
|
+
built.icon.setImageResource(drawableRes)
|
|
342
|
+
built.icon.setColorFilter(tint)
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
built.title.text = title
|
|
346
|
+
if (message.isNotEmpty()) {
|
|
347
|
+
built.message.text = message
|
|
348
|
+
built.message.visibility = View.VISIBLE
|
|
349
|
+
} else {
|
|
350
|
+
built.message.visibility = View.GONE
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return tint
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
private fun bindAction(
|
|
357
|
+
built: ToastViewFactory.Built,
|
|
358
|
+
label: String,
|
|
359
|
+
actionColor: Int,
|
|
360
|
+
) {
|
|
361
|
+
if (label.isEmpty()) {
|
|
362
|
+
built.actionButton.visibility = View.GONE
|
|
363
|
+
built.actionButton.setOnClickListener(null)
|
|
364
|
+
return
|
|
365
|
+
}
|
|
366
|
+
built.actionButton.visibility = View.VISIBLE
|
|
367
|
+
built.actionButton.text = label
|
|
368
|
+
built.actionButton.setTextColor(actionColor)
|
|
369
|
+
built.actionButton.setOnClickListener { onActionPress?.invoke() }
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Accepts http(s)://, file://, and absolute filesystem paths. Bundled
|
|
374
|
+
* RN asset URIs in prod (resource names) are not supported here — users
|
|
375
|
+
* passing `require('./icon.png')` will get the dev-mode http URL in dev
|
|
376
|
+
* and will need a file URI in prod. Decoded bitmaps are cached in
|
|
377
|
+
* [iconCache] so repeat shows apply synchronously.
|
|
378
|
+
*/
|
|
379
|
+
private fun loadIcon(built: ToastViewFactory.Built, uri: String) {
|
|
380
|
+
val targetW = built.icon.layoutParams.width.takeIf { it > 0 } ?: DEFAULT_ICON_TARGET_PX
|
|
381
|
+
val targetH = built.icon.layoutParams.height.takeIf { it > 0 } ?: DEFAULT_ICON_TARGET_PX
|
|
382
|
+
imageLoader.execute {
|
|
383
|
+
val bitmap = try {
|
|
384
|
+
when {
|
|
385
|
+
uri.startsWith("http://") || uri.startsWith("https://") -> {
|
|
386
|
+
val bytes = URL(uri).openStream().use { it.readBytes() }
|
|
387
|
+
decodeSampled(bytes, targetW, targetH)
|
|
388
|
+
}
|
|
389
|
+
uri.startsWith("data:") -> decodeSampled(decodeDataUri(uri), targetW, targetH)
|
|
390
|
+
uri.startsWith("file://") -> decodeSampled(uri.removePrefix("file://"), targetW, targetH)
|
|
391
|
+
uri.startsWith("/") -> decodeSampled(uri, targetW, targetH)
|
|
392
|
+
else -> null
|
|
393
|
+
}
|
|
394
|
+
} catch (_: Throwable) {
|
|
395
|
+
null
|
|
396
|
+
} ?: return@execute
|
|
397
|
+
|
|
398
|
+
iconCache.put(uri, bitmap)
|
|
399
|
+
handler.post {
|
|
400
|
+
// Only apply if this URI is still the one we want to render.
|
|
401
|
+
// A later show() with a different URI cancels this load.
|
|
402
|
+
if (currentImageUri == uri) {
|
|
403
|
+
built.icon.setImageBitmap(bitmap)
|
|
404
|
+
built.icon.colorFilter = null
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
private fun decodeSampled(bytes: ByteArray, targetW: Int, targetH: Int): Bitmap? {
|
|
411
|
+
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
|
412
|
+
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
|
|
413
|
+
val opts = BitmapFactory.Options().apply {
|
|
414
|
+
inSampleSize = calcInSampleSize(bounds.outWidth, bounds.outHeight, targetW, targetH)
|
|
415
|
+
}
|
|
416
|
+
return BitmapFactory.decodeByteArray(bytes, 0, bytes.size, opts)
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
private fun decodeDataUri(uri: String): ByteArray {
|
|
420
|
+
val commaIndex = uri.indexOf(',')
|
|
421
|
+
if (commaIndex == -1) return ByteArray(0)
|
|
422
|
+
|
|
423
|
+
val metadata = uri.substring(0, commaIndex)
|
|
424
|
+
val payload = uri.substring(commaIndex + 1)
|
|
425
|
+
|
|
426
|
+
return if (metadata.contains(";base64")) {
|
|
427
|
+
Base64.decode(payload, Base64.DEFAULT)
|
|
428
|
+
} else {
|
|
429
|
+
val decodedPayload = java.net.URLDecoder.decode(payload, Charsets.UTF_8)
|
|
430
|
+
decodedPayload.toByteArray()
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
private fun decodeSampled(path: String, targetW: Int, targetH: Int): Bitmap? {
|
|
435
|
+
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
|
436
|
+
BitmapFactory.decodeFile(path, bounds)
|
|
437
|
+
val opts = BitmapFactory.Options().apply {
|
|
438
|
+
inSampleSize = calcInSampleSize(bounds.outWidth, bounds.outHeight, targetW, targetH)
|
|
439
|
+
}
|
|
440
|
+
return BitmapFactory.decodeFile(path, opts)
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
private fun calcInSampleSize(srcW: Int, srcH: Int, dstW: Int, dstH: Int): Int {
|
|
444
|
+
if (srcW <= 0 || srcH <= 0 || dstW <= 0 || dstH <= 0) return 1
|
|
445
|
+
var sample = 1
|
|
446
|
+
while (srcW / (sample * 2) >= dstW && srcH / (sample * 2) >= dstH) {
|
|
447
|
+
sample *= 2
|
|
448
|
+
}
|
|
449
|
+
return sample
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
private fun installGestures(
|
|
453
|
+
built: ToastViewFactory.Built,
|
|
454
|
+
animator: ToastAnimator,
|
|
455
|
+
enableSwipeDismiss: Boolean,
|
|
456
|
+
) {
|
|
457
|
+
val resources = activity?.resources ?: return
|
|
458
|
+
val density = Density.from(resources)
|
|
459
|
+
ToastGestureHandler(
|
|
460
|
+
animator = animator,
|
|
461
|
+
density = density,
|
|
462
|
+
enableSwipeDismiss = enableSwipeDismiss,
|
|
463
|
+
onDismissRequested = { dismiss() },
|
|
464
|
+
onPress = { onPress?.invoke() },
|
|
465
|
+
).install(built.pill)
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
private fun cancelAutoDismiss() {
|
|
469
|
+
dismissRunnable?.let { handler.removeCallbacks(it) }
|
|
470
|
+
dismissRunnable = null
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// The animator completion already fires after the collapse animation ends;
|
|
474
|
+
// the extra delay here is a grace window so a queued toast's show() can
|
|
475
|
+
// cancel the restore before the status bar visibly flashes.
|
|
476
|
+
private fun scheduleStatusBarRestore() {
|
|
477
|
+
cancelStatusBarRestore()
|
|
478
|
+
val runnable = Runnable {
|
|
479
|
+
activity?.let { StatusBarController.show(it) }
|
|
480
|
+
}
|
|
481
|
+
statusBarRestoreRunnable = runnable
|
|
482
|
+
handler.postDelayed(runnable, STATUS_BAR_RESTORE_GRACE_MS)
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
private fun cancelStatusBarRestore() {
|
|
486
|
+
statusBarRestoreRunnable?.let { handler.removeCallbacks(it) }
|
|
487
|
+
statusBarRestoreRunnable = null
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
companion object {
|
|
491
|
+
private const val STATUS_BAR_RESTORE_GRACE_MS = 250L
|
|
492
|
+
// Fallback decode target if the ImageView layout params are WRAP/MATCH.
|
|
493
|
+
private const val DEFAULT_ICON_TARGET_PX = 128
|
|
494
|
+
}
|
|
495
|
+
}
|