@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,235 @@
1
+ package com.toast.anim
2
+
3
+ import android.animation.ValueAnimator
4
+ import android.graphics.drawable.GradientDrawable
5
+ import android.view.View
6
+ import android.widget.LinearLayout
7
+ import com.toast.cutout.CutoutInfo
8
+ import com.toast.util.Density
9
+ import com.toast.util.ToastConstants.MORPH_DURATION_MS
10
+ import com.toast.util.ToastConstants.MORPH_EASING
11
+
12
+ /**
13
+ * Dynamic-Island-style morph animation. The pill scales + translates
14
+ * between the expanded capsule and a tight footprint over the cutout,
15
+ * while cornerRadii morph so the on-screen corner stays circular
16
+ * throughout the anisotropic scale.
17
+ */
18
+ class CutoutMorphAnimator(
19
+ private val pill: LinearLayout,
20
+ private val content: View,
21
+ private val info: CutoutInfo,
22
+ private val expandedCornerRadius: Float,
23
+ private val density: Density,
24
+ private val onBeforeShow: () -> Unit = {},
25
+ private val onBeforeDismiss: () -> Unit = {},
26
+ ) : ToastAnimator {
27
+
28
+ private var cornerAnimator: ValueAnimator? = null
29
+ private val cornerRadiiBuf = FloatArray(8)
30
+
31
+ /**
32
+ * Lower bound for the measured pill height — guards against racing
33
+ * the layout pass, where `pill.height` can briefly read as 0.
34
+ */
35
+ private val heightFloor: Float get() = density.dp(70f)
36
+
37
+ private fun expandedHeight(): Float = pill.height.toFloat().coerceAtLeast(heightFloor)
38
+
39
+ override fun show() {
40
+ onBeforeShow()
41
+
42
+ val expandedWidth = pill.layoutParams.width.toFloat()
43
+ val scaleX = info.collapsedWidth / expandedWidth
44
+ val scaleY = info.collapsedHeight / expandedHeight()
45
+
46
+ pill.pivotX = expandedWidth / 2f
47
+ pill.pivotY = 0f
48
+ pill.scaleX = scaleX
49
+ pill.scaleY = scaleY
50
+ // Translate so the collapsed pill starts centered over the cutout
51
+ // (zero offset for centered holes, non-zero for corner cameras).
52
+ pill.translationX = info.horizontalOffset
53
+ pill.translationY = 0f
54
+ pill.alpha = 1f
55
+ content.alpha = 0f
56
+ // Start with fully-capsule corners so the initial frame reads as a
57
+ // pill hugging the camera, not a tiny rectangle.
58
+ applyMorphCorners(1f)
59
+
60
+ // Material standard ease-in-out — gentle accel, soft landing.
61
+ // Duration matches iOS's `.bouncy(duration: 0.3)`.
62
+ pill.animate()
63
+ .scaleX(1f)
64
+ .scaleY(1f)
65
+ .translationX(0f)
66
+ .setDuration(MORPH_DURATION_MS)
67
+ .setInterpolator(MORPH_EASING)
68
+ .start()
69
+ animateMorphCorners(fromProgress = 1f, toProgress = 0f, durationMs = MORPH_DURATION_MS)
70
+
71
+ content.animate()
72
+ .alpha(1f)
73
+ .setDuration(210)
74
+ .setStartDelay(80)
75
+ .start()
76
+ }
77
+
78
+ override fun dismiss(onEnd: () -> Unit) {
79
+ onBeforeDismiss()
80
+
81
+ val expandedWidth = pill.width.toFloat().coerceAtLeast(1f)
82
+ val expandedHeight = expandedHeight()
83
+ val scaleX = info.collapsedWidth / expandedWidth
84
+ val scaleY = info.collapsedHeight / expandedHeight
85
+
86
+ // Clear any stale drag translation and align pivots with show()
87
+ // so the morph collapses exactly onto the cutout.
88
+ pill.translationY = 0f
89
+ pill.pivotX = expandedWidth / 2f
90
+ pill.pivotY = 0f
91
+
92
+ content.animate()
93
+ .alpha(0f)
94
+ .setDuration(140)
95
+ .start()
96
+
97
+ // Infer current progress from pill.scaleY so the corner animation
98
+ // picks up from wherever the drag left it.
99
+ val minScaleY = info.collapsedHeight / expandedHeight
100
+ val currentProgress = if (minScaleY < 1f)
101
+ ((1f - pill.scaleY) / (1f - minScaleY)).coerceIn(0f, 1f)
102
+ else 0f
103
+
104
+ // Scale down and fade out simultaneously — no two-step pop.
105
+ pill.animate()
106
+ .scaleX(scaleX)
107
+ .scaleY(scaleY)
108
+ .translationX(info.horizontalOffset)
109
+ .alpha(0f)
110
+ .setDuration(MORPH_DURATION_MS)
111
+ .setInterpolator(MORPH_EASING)
112
+ .withEndAction {
113
+ resetPillToExpandedResting()
114
+ onEnd()
115
+ }
116
+ .start()
117
+ animateMorphCorners(fromProgress = currentProgress, toProgress = 1f, durationMs = MORPH_DURATION_MS)
118
+ }
119
+
120
+ override fun applyDrag(dy: Float, translationYOnDown: Float) {
121
+ if (dy >= 0f) return // drag morph is upward-only
122
+ applyDragMorph(-dy)
123
+ }
124
+
125
+ override fun snapBack() {
126
+ // Release-without-dismiss during a morph drag: spring back to full
127
+ // size and restore content opacity.
128
+ pill.animate().cancel()
129
+ pill.animate()
130
+ .scaleX(1f)
131
+ .scaleY(1f)
132
+ .translationX(0f)
133
+ .setDuration(MORPH_DURATION_MS)
134
+ .setInterpolator(MORPH_EASING)
135
+ .start()
136
+
137
+ val minScaleY = info.collapsedHeight / expandedHeight()
138
+ val currentProgress = if (minScaleY < 1f)
139
+ ((1f - pill.scaleY) / (1f - minScaleY)).coerceIn(0f, 1f)
140
+ else 0f
141
+ animateMorphCorners(currentProgress, 0f, MORPH_DURATION_MS)
142
+
143
+ content.animate().cancel()
144
+ content.animate()
145
+ .alpha(1f)
146
+ .setDuration(220)
147
+ .start()
148
+ }
149
+
150
+ /**
151
+ * Interpolates the pill's scale from fully expanded toward the cutout
152
+ * footprint. `upwardDistance` is positive px of finger travel up.
153
+ */
154
+ private fun applyDragMorph(upwardDistance: Float) {
155
+ val expandedWidth = pill.width.toFloat().coerceAtLeast(1f)
156
+ val expandedHeight = expandedHeight()
157
+ val collapsedScaleX = info.collapsedWidth / expandedWidth
158
+ val collapsedScaleY = info.collapsedHeight / expandedHeight
159
+
160
+ val fullCollapseDistance = density.dp(140f)
161
+ val progress = (upwardDistance / fullCollapseDistance).coerceIn(0f, 1f)
162
+
163
+ pill.pivotX = expandedWidth / 2f
164
+ pill.pivotY = 0f
165
+ pill.scaleX = 1f - progress * (1f - collapsedScaleX)
166
+ pill.scaleY = 1f - progress * (1f - collapsedScaleY)
167
+ pill.translationX = info.horizontalOffset * progress
168
+
169
+ cornerAnimator?.cancel()
170
+ applyMorphCorners(progress)
171
+ // Fade content out quickly in the first half so the morph reads
172
+ // as a shape change, not text squeezing.
173
+ content.alpha = (1f - progress * 2f).coerceIn(0f, 1f)
174
+ }
175
+
176
+ /**
177
+ * Sets cornerRadii so the visually-rendered corners stay circular
178
+ * throughout the anisotropic scale. With a single cornerRadius,
179
+ * non-uniform scale turns corners into squished ellipses — we
180
+ * compensate by feeding pre-scale x/y radii that project to the
181
+ * desired visible radius after scaleX/scaleY.
182
+ *
183
+ * progress: 0 = fully expanded, 1 = fully collapsed to cutout.
184
+ */
185
+ private fun applyMorphCorners(progress: Float) {
186
+ val bg = pill.background as? GradientDrawable ?: return
187
+ val pillW = pill.layoutParams.width.toFloat().coerceAtLeast(1f)
188
+ val pillH = expandedHeight()
189
+ val scaleX = 1f - progress * (1f - info.collapsedWidth / pillW)
190
+ val scaleY = 1f - progress * (1f - info.collapsedHeight / pillH)
191
+
192
+ val collapsedCap = minOf(info.collapsedWidth, info.collapsedHeight) / 2f
193
+ val visibleCap = expandedCornerRadius + (collapsedCap - expandedCornerRadius) * progress
194
+
195
+ val rx = visibleCap / scaleX
196
+ val ry = visibleCap / scaleY
197
+ // Reuse the buffer — applyMorphCorners runs per-frame during drag.
198
+ cornerRadiiBuf[0] = rx; cornerRadiiBuf[1] = ry
199
+ cornerRadiiBuf[2] = rx; cornerRadiiBuf[3] = ry
200
+ cornerRadiiBuf[4] = rx; cornerRadiiBuf[5] = ry
201
+ cornerRadiiBuf[6] = rx; cornerRadiiBuf[7] = ry
202
+ bg.cornerRadii = cornerRadiiBuf
203
+ }
204
+
205
+ /**
206
+ * Drives `applyMorphCorners` in lockstep with the scale/translation
207
+ * animation — same duration + interpolator so the two stay in sync
208
+ * frame-for-frame.
209
+ */
210
+ private fun animateMorphCorners(fromProgress: Float, toProgress: Float, durationMs: Long) {
211
+ cornerAnimator?.cancel()
212
+ cornerAnimator = ValueAnimator.ofFloat(fromProgress, toProgress).apply {
213
+ duration = durationMs
214
+ interpolator = MORPH_EASING
215
+ addUpdateListener { applyMorphCorners(it.animatedValue as Float) }
216
+ start()
217
+ }
218
+ }
219
+
220
+ private fun resetPillToExpandedResting() {
221
+ pill.scaleX = 1f
222
+ pill.scaleY = 1f
223
+ pill.translationX = 0f
224
+ pill.alpha = 1f
225
+ content.alpha = 1f
226
+ (pill.background as? GradientDrawable)?.apply {
227
+ cornerRadii = null
228
+ cornerRadius = expandedCornerRadius
229
+ }
230
+ }
231
+
232
+ fun cancelPendingCallbacks() {
233
+ cornerAnimator?.cancel()
234
+ }
235
+ }
@@ -0,0 +1,64 @@
1
+ package com.toast.anim
2
+
3
+ import android.widget.LinearLayout
4
+ import androidx.dynamicanimation.animation.DynamicAnimation
5
+ import androidx.dynamicanimation.animation.SpringAnimation
6
+ import androidx.dynamicanimation.animation.SpringForce
7
+ import com.toast.util.Density
8
+
9
+ /**
10
+ * Fallback animation for devices without a top cutout — a spring-based
11
+ * drop-down from offscreen, with matching spring on snap-back and a
12
+ * faster damped spring on dismiss.
13
+ */
14
+ class SlideAnimator(
15
+ private val pill: LinearLayout,
16
+ private val density: Density,
17
+ ) : ToastAnimator {
18
+
19
+ private fun offscreenY(): Float = -density.dp(200f)
20
+
21
+ override fun show() {
22
+ pill.translationY = offscreenY()
23
+ pill.alpha = 1f
24
+
25
+ SpringAnimation(pill, DynamicAnimation.TRANSLATION_Y, 0f).apply {
26
+ spring.apply {
27
+ dampingRatio = 0.75f
28
+ stiffness = SpringForce.STIFFNESS_MEDIUM
29
+ }
30
+ start()
31
+ }
32
+ }
33
+
34
+ override fun dismiss(onEnd: () -> Unit) {
35
+ val spring = SpringAnimation(pill, DynamicAnimation.TRANSLATION_Y, offscreenY()).apply {
36
+ spring.apply {
37
+ dampingRatio = SpringForce.DAMPING_RATIO_NO_BOUNCY
38
+ stiffness = SpringForce.STIFFNESS_MEDIUM
39
+ }
40
+ addEndListener { _, _, _, _ -> onEnd() }
41
+ }
42
+ spring.start()
43
+
44
+ pill.animate()
45
+ .alpha(0f)
46
+ .setDuration(250)
47
+ .start()
48
+ }
49
+
50
+ override fun applyDrag(dy: Float, translationYOnDown: Float) {
51
+ if (dy >= 0f) return // upward drag only
52
+ pill.translationY = translationYOnDown + dy
53
+ }
54
+
55
+ override fun snapBack() {
56
+ SpringAnimation(pill, DynamicAnimation.TRANSLATION_Y, 0f).apply {
57
+ spring.apply {
58
+ dampingRatio = 0.75f
59
+ stiffness = SpringForce.STIFFNESS_MEDIUM
60
+ }
61
+ start()
62
+ }
63
+ }
64
+ }
@@ -0,0 +1,23 @@
1
+ package com.toast.anim
2
+
3
+ /**
4
+ * Common surface for the two toast animation strategies (cutout-morph
5
+ * and slide-spring) so the gesture handler doesn't branch on which one
6
+ * is active.
7
+ */
8
+ interface ToastAnimator {
9
+ fun show()
10
+ fun dismiss(onEnd: () -> Unit)
11
+
12
+ /**
13
+ * Called on every ACTION_MOVE of an in-progress pill drag.
14
+ *
15
+ * @param dy raw finger delta since ACTION_DOWN (negative = upward).
16
+ * @param translationYOnDown pill.translationY captured at ACTION_DOWN
17
+ * — used by the slide animator to resume relative translation.
18
+ */
19
+ fun applyDrag(dy: Float, translationYOnDown: Float)
20
+
21
+ /** Release without dismiss: restore to the resting expanded state. */
22
+ fun snapBack()
23
+ }
@@ -0,0 +1,142 @@
1
+ package com.toast.backdrop
2
+
3
+ import android.app.Activity
4
+ import android.graphics.Bitmap
5
+ import android.graphics.Rect
6
+ import android.os.Handler
7
+ import android.os.Looper
8
+ import android.view.PixelCopy
9
+ import com.toast.util.Density
10
+ import java.lang.ref.WeakReference
11
+
12
+ enum class BackdropTint { COLORED, GRAY }
13
+
14
+ /**
15
+ * Samples the pixels beneath the toast pill on a 250 ms tick and flips
16
+ * [onTintChanged] between [BackdropTint.COLORED] (dark backdrop — use the
17
+ * toast's accent colour as the outline) and [BackdropTint.GRAY] (everything
18
+ * lighter — a faint neutral white outline).
19
+ *
20
+ * Mirrors the iOS sampler in `PassThroughWindow.swift`: a 32×8 px bitmap,
21
+ * Rec. 601 luminance, and flip points at 0.050 / 0.060 with hysteresis. iOS
22
+ * uses `CALayer.render(in:)`; we use [PixelCopy.request] against the
23
+ * activity's window, which reads the actual GPU-rendered surface (including
24
+ * our overlay — the self-captured black pill biases the average towards
25
+ * `COLORED` on dark backdrops, which is the correct outcome, and stays well
26
+ * above the flip threshold on any light backdrop where the surrounding
27
+ * pixels dominate the average).
28
+ */
29
+ class BackdropSampler(
30
+ activity: Activity,
31
+ density: Density,
32
+ private val onTintChanged: (BackdropTint) -> Unit,
33
+ ) {
34
+ private val activityRef = WeakReference(activity)
35
+ private val stripHeightPx = density.dpInt(SAMPLE_STRIP_HEIGHT_DP)
36
+ private val handler = Handler(Looper.getMainLooper())
37
+ private var bitmap: Bitmap? =
38
+ Bitmap.createBitmap(BITMAP_WIDTH, BITMAP_HEIGHT, Bitmap.Config.ARGB_8888)
39
+ private val pixels = IntArray(BITMAP_WIDTH * BITMAP_HEIGHT)
40
+ private val srcRect = Rect()
41
+
42
+ private var tickRunnable: Runnable? = null
43
+ private var inflight = false
44
+ private var stopped = false
45
+ private var currentTint: BackdropTint = BackdropTint.GRAY
46
+
47
+ fun start() {
48
+ stop()
49
+ stopped = false
50
+ sample()
51
+ val r = object : Runnable {
52
+ override fun run() {
53
+ sample()
54
+ handler.postDelayed(this, SAMPLE_INTERVAL_MS)
55
+ }
56
+ }
57
+ tickRunnable = r
58
+ handler.postDelayed(r, SAMPLE_INTERVAL_MS)
59
+ }
60
+
61
+ /**
62
+ * Stops the tick loop and releases the backing bitmap. Safe to call
63
+ * multiple times. If a [PixelCopy] request is still in flight the
64
+ * bitmap is recycled inside the callback instead — recycling it here
65
+ * would crash the native copy.
66
+ */
67
+ fun stop() {
68
+ stopped = true
69
+ tickRunnable?.let { handler.removeCallbacks(it) }
70
+ tickRunnable = null
71
+ if (!inflight) {
72
+ bitmap?.recycle()
73
+ bitmap = null
74
+ }
75
+ }
76
+
77
+ /** Current tint, so the overlay can sync a freshly-built pill. */
78
+ val tint: BackdropTint get() = currentTint
79
+
80
+ private fun sample() {
81
+ if (inflight || stopped) return
82
+ val activity = activityRef.get() ?: return
83
+ val window = activity.window ?: return
84
+ val decorView = window.decorView ?: return
85
+ val w = decorView.width
86
+ val h = decorView.height
87
+ if (w <= 0 || h <= 0) return
88
+ val dst = bitmap ?: return
89
+ srcRect.set(0, 0, w, stripHeightPx.coerceAtMost(h))
90
+
91
+ inflight = true
92
+ try {
93
+ PixelCopy.request(window, srcRect, dst, { result ->
94
+ inflight = false
95
+ // If stop() was called while the copy was in flight it
96
+ // deferred the recycle to us — do it now, and bail out of
97
+ // any further processing.
98
+ if (stopped) {
99
+ bitmap?.recycle()
100
+ bitmap = null
101
+ return@request
102
+ }
103
+ if (result == PixelCopy.SUCCESS) processBitmap(dst)
104
+ }, handler)
105
+ } catch (_: IllegalArgumentException) {
106
+ inflight = false
107
+ }
108
+ }
109
+
110
+ private fun processBitmap(src: Bitmap) {
111
+ src.getPixels(pixels, 0, BITMAP_WIDTH, 0, 0, BITMAP_WIDTH, BITMAP_HEIGHT)
112
+
113
+ var total = 0.0
114
+ for (p in pixels) {
115
+ val r = ((p shr 16) and 0xFF) / 255.0
116
+ val g = ((p shr 8) and 0xFF) / 255.0
117
+ val b = (p and 0xFF) / 255.0
118
+ total += 0.299 * r + 0.587 * g + 0.114 * b
119
+ }
120
+ val avg = total / pixels.size
121
+
122
+ // Single flip point at ~#0E (≈0.055), matching Apple's DI. ±0.005
123
+ // hysteresis stops backdrops right on the boundary flickering.
124
+ val next = when (currentTint) {
125
+ BackdropTint.COLORED -> if (avg > FLIP_HIGH) BackdropTint.GRAY else BackdropTint.COLORED
126
+ BackdropTint.GRAY -> if (avg < FLIP_LOW) BackdropTint.COLORED else BackdropTint.GRAY
127
+ }
128
+ if (next != currentTint) {
129
+ currentTint = next
130
+ onTintChanged(next)
131
+ }
132
+ }
133
+
134
+ companion object {
135
+ private const val SAMPLE_INTERVAL_MS = 250L
136
+ private const val BITMAP_WIDTH = 32
137
+ private const val BITMAP_HEIGHT = 8
138
+ private const val SAMPLE_STRIP_HEIGHT_DP = 80f
139
+ private const val FLIP_LOW = 0.050
140
+ private const val FLIP_HIGH = 0.060
141
+ }
142
+ }
@@ -0,0 +1,100 @@
1
+ package com.toast.backdrop
2
+
3
+ import android.animation.ArgbEvaluator
4
+ import android.animation.ValueAnimator
5
+ import android.graphics.Color
6
+ import android.graphics.drawable.GradientDrawable
7
+ import android.view.animation.AccelerateDecelerateInterpolator
8
+
9
+ /**
10
+ * Drives the outline colour on the toast pill's [GradientDrawable] stroke.
11
+ *
12
+ * Mirrors the iOS renderer in `PrettyToastView.swift`: on `.colored` tint the
13
+ * stroke takes the toast's accent at 20% alpha; on `.gray` it falls back to a
14
+ * near-invisible white at 6%. Tint changes crossfade over 300 ms via a
15
+ * `ValueAnimator` + `ArgbEvaluator`, matching the iOS
16
+ * `.animation(.easeInOut(duration: 0.3))` on stroke opacity.
17
+ */
18
+ class OutlineController(
19
+ private val pillBackground: GradientDrawable,
20
+ private val strokeWidthPx: Int,
21
+ ) {
22
+ private var accentColor: Int = Color.WHITE
23
+ private var currentTint: BackdropTint = BackdropTint.GRAY
24
+ private var currentStrokeColor: Int = Color.TRANSPARENT
25
+ private var animator: ValueAnimator? = null
26
+ /**
27
+ * When non-null, overrides the sampler-driven stroke with a fixed color.
28
+ * Used by the JS-facing `strokeColor` prop; treated as the full ARGB
29
+ * value (alpha included — no further opacity derivation).
30
+ */
31
+ private var override: Int? = null
32
+
33
+ init {
34
+ applyStroke(strokeColorFor(currentTint))
35
+ }
36
+
37
+ fun setAccent(accent: Int) {
38
+ if (accent == accentColor) return
39
+ accentColor = accent
40
+ val target = strokeColorFor(currentTint)
41
+ animator?.cancel()
42
+ applyStroke(target)
43
+ }
44
+
45
+ fun setTint(tint: BackdropTint, animated: Boolean) {
46
+ if (tint == currentTint) return
47
+ currentTint = tint
48
+ if (override != null) return
49
+ val target = strokeColorFor(tint)
50
+ if (!animated) {
51
+ animator?.cancel()
52
+ applyStroke(target)
53
+ return
54
+ }
55
+ animateStrokeTo(target)
56
+ }
57
+
58
+ fun setOverride(color: Int?) {
59
+ if (override == color) return
60
+ override = color
61
+ animator?.cancel()
62
+ applyStroke(color ?: strokeColorFor(currentTint))
63
+ }
64
+
65
+ private fun strokeColorFor(tint: BackdropTint): Int = override ?: when (tint) {
66
+ BackdropTint.COLORED -> withAlpha(accentColor, ACCENT_ALPHA_255)
67
+ BackdropTint.GRAY -> withAlpha(Color.WHITE, GRAY_ALPHA_255)
68
+ }
69
+
70
+ private fun withAlpha(color: Int, alpha: Int): Int =
71
+ (color and 0x00FFFFFF) or ((alpha and 0xFF) shl 24)
72
+
73
+ private fun applyStroke(color: Int) {
74
+ currentStrokeColor = color
75
+ pillBackground.setStroke(strokeWidthPx, color)
76
+ }
77
+
78
+ private fun animateStrokeTo(target: Int) {
79
+ animator?.cancel()
80
+ val start = currentStrokeColor
81
+ animator = ValueAnimator.ofObject(ArgbEvaluator(), start, target).apply {
82
+ duration = CROSSFADE_MS
83
+ interpolator = AccelerateDecelerateInterpolator()
84
+ addUpdateListener { a -> applyStroke(a.animatedValue as Int) }
85
+ start()
86
+ }
87
+ }
88
+
89
+ fun cancel() {
90
+ animator?.cancel()
91
+ animator = null
92
+ }
93
+
94
+ companion object {
95
+ // 0.20 × 255 = 51, 0.06 × 255 ≈ 15 — match iOS stroke alphas.
96
+ private const val ACCENT_ALPHA_255 = 51
97
+ private const val GRAY_ALPHA_255 = 15
98
+ private const val CROSSFADE_MS = 300L
99
+ }
100
+ }
@@ -0,0 +1,88 @@
1
+ package com.toast.cutout
2
+
3
+ import android.app.Activity
4
+ import android.graphics.Rect
5
+ import android.os.Build
6
+ import android.view.RoundedCorner
7
+ import android.view.View
8
+ import androidx.core.view.ViewCompat
9
+ import androidx.core.view.WindowInsetsCompat
10
+ import com.toast.util.ToastConstants.COLLAPSED_CUTOUT_FACTOR
11
+
12
+ /**
13
+ * Reads DisplayCutout + rounded-corner insets off the decor view and
14
+ * packages them into an immutable [CutoutInfo] snapshot.
15
+ */
16
+ object CutoutDetector {
17
+
18
+ fun detect(activity: Activity, decorView: View, useDynamicIsland: Boolean): CutoutInfo {
19
+ val screenWidth = activity.resources.displayMetrics.widthPixels
20
+ val density = activity.resources.displayMetrics.density
21
+ val insets = ViewCompat.getRootWindowInsets(decorView)
22
+
23
+ val statusBarHeight = resolveStatusBarHeight(activity, insets)
24
+ val screenCornerRadius = resolveScreenCornerRadius(decorView)
25
+
26
+ val cutoutRect = if (useDynamicIsland) {
27
+ detectTopCutoutRect(decorView, statusBarHeight)
28
+ } else {
29
+ null
30
+ }
31
+
32
+ val hasCutout = cutoutRect != null
33
+
34
+ val collapsedWidth: Float
35
+ val collapsedHeight: Float
36
+ val horizontalOffset: Float
37
+ if (cutoutRect != null) {
38
+ collapsedWidth = cutoutRect.width() * COLLAPSED_CUTOUT_FACTOR
39
+ collapsedHeight = cutoutRect.height() * COLLAPSED_CUTOUT_FACTOR
40
+ val screenCenterX = screenWidth / 2f
41
+ horizontalOffset = cutoutRect.centerX() - screenCenterX
42
+ } else {
43
+ collapsedWidth = 120f * density
44
+ collapsedHeight = 36f * density
45
+ horizontalOffset = 0f
46
+ }
47
+
48
+ return CutoutInfo(
49
+ hasCutout = hasCutout,
50
+ rect = cutoutRect,
51
+ collapsedWidth = collapsedWidth,
52
+ collapsedHeight = collapsedHeight,
53
+ horizontalOffset = horizontalOffset,
54
+ screenCornerRadius = screenCornerRadius,
55
+ statusBarHeight = statusBarHeight,
56
+ )
57
+ }
58
+
59
+ private fun detectTopCutoutRect(decorView: View, statusBarHeight: Int): Rect? {
60
+ val insets = ViewCompat.getRootWindowInsets(decorView) ?: return null
61
+ val cutout = insets.displayCutout ?: return null
62
+
63
+ // Enable Dynamic-Island-style behavior for any top cutout —
64
+ // centered punch-hole, corner camera, or notch. The morph
65
+ // animation uses horizontal translation so it lands correctly
66
+ // on off-center holes.
67
+ val topRect = cutout.boundingRects.firstOrNull { it.top == 0 || it.top < statusBarHeight }
68
+ return topRect?.takeIf { !it.isEmpty }
69
+ }
70
+
71
+ private fun resolveStatusBarHeight(activity: Activity, insets: WindowInsetsCompat?): Int {
72
+ val fromInsets = insets?.getInsets(WindowInsetsCompat.Type.statusBars())?.top ?: 0
73
+ if (fromInsets > 0) return fromInsets
74
+ val resourceId = activity.resources.getIdentifier("status_bar_height", "dimen", "android")
75
+ return if (resourceId > 0) activity.resources.getDimensionPixelSize(resourceId) else 0
76
+ }
77
+
78
+ private fun resolveScreenCornerRadius(decorView: View): Int {
79
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
80
+ val insets = decorView.rootWindowInsets
81
+ val topLeft = insets?.getRoundedCorner(RoundedCorner.POSITION_TOP_LEFT)
82
+ val topRight = insets?.getRoundedCorner(RoundedCorner.POSITION_TOP_RIGHT)
83
+ val radius = maxOf(topLeft?.radius ?: 0, topRight?.radius ?: 0)
84
+ if (radius > 0) return radius
85
+ }
86
+ return 0
87
+ }
88
+ }
@@ -0,0 +1,28 @@
1
+ package com.toast.cutout
2
+
3
+ import android.graphics.Rect
4
+
5
+ /**
6
+ * Snapshot of cutout/screen geometry captured once per toast show.
7
+ *
8
+ * Replaces the per-access computed properties the old monolithic overlay
9
+ * had for `collapsedWidth`, `collapsedHeight`, `cutoutHorizontalOffset`,
10
+ * etc. — we read these many times per animation frame, so paying the
11
+ * measurement cost once up-front matters.
12
+ */
13
+ data class CutoutInfo(
14
+ val hasCutout: Boolean,
15
+ val rect: Rect?,
16
+ /** Width the pill morphs to when collapsed onto the cutout. */
17
+ val collapsedWidth: Float,
18
+ /** Height the pill morphs to when collapsed onto the cutout. */
19
+ val collapsedHeight: Float,
20
+ /**
21
+ * Horizontal translation (px) needed to slide a centered pill so
22
+ * its center aligns with the cutout. Zero for centered punch-holes,
23
+ * non-zero for corner cameras.
24
+ */
25
+ val horizontalOffset: Float,
26
+ val screenCornerRadius: Int,
27
+ val statusBarHeight: Int,
28
+ )