craft-native 0.0.234 → 0.0.236

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.
@@ -8,6 +8,7 @@ import android.content.Context
8
8
  import android.content.pm.PackageManager
9
9
  import android.content.res.ColorStateList
10
10
  import android.content.res.Configuration
11
+ import android.content.res.Resources
11
12
  import android.database.ContentObserver
12
13
  import android.content.Intent
13
14
  import android.app.PictureInPictureParams
@@ -865,8 +866,146 @@ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMap
865
866
  private fun color(value: String): Int = runCatching { craftNativeColor(value) }.getOrDefault(Color.BLUE)
866
867
  }
867
868
 
869
+ /** A finite celebration owned by a View, never by the JavaScript frame loop. */
870
+ internal class CraftNativeCelebration(private val view: View) {
871
+ private var request: Pair<Double, Boolean>? = null
872
+ private var pending = false
873
+ private var animator: ValueAnimator? = null
874
+ private var decoration: Drawable? = null
875
+ private var root: View? = null
876
+ private var radius = 0f
877
+ var starts = 0
878
+ private set
879
+ val isRunning: Boolean get() = animator != null
880
+
881
+ fun configure(raw: JSONObject?, radius: Float) {
882
+ val replay = craftNativeNumber(raw?.opt("replay"))?.takeIf { it >= 0 }
883
+ val next = replay?.let { it to (raw?.opt("enabled") != false) }
884
+ this.radius = radius
885
+ if (next == request) return
886
+ stop()
887
+ request = next
888
+ pending = next?.second == true
889
+ view.requestLayout()
890
+ }
891
+
892
+ fun stop() {
893
+ pending = false
894
+ animator?.removeAllUpdateListeners()
895
+ animator?.removeAllListeners()
896
+ animator?.cancel()
897
+ animator = null
898
+ decoration?.let { root?.overlay?.remove(it) }
899
+ decoration = null
900
+ root = null
901
+ view.clearAnimation()
902
+ }
903
+
904
+ fun layout(animationsEnabled: Boolean = ValueAnimator.areAnimatorsEnabled()) {
905
+ if (!pending || !view.isAttachedToWindow || !view.isShown || view.width <= 0 || view.height <= 0) return
906
+ pending = false
907
+ if (!animationsEnabled) return
908
+ starts += 1
909
+ val root = view.rootView
910
+ this.root = root
911
+ val at = IntArray(2)
912
+ val origin = IntArray(2)
913
+ view.getLocationInWindow(at)
914
+ root.getLocationInWindow(origin)
915
+ val frame = RectF((at[0] - origin[0]).toFloat(), (at[1] - origin[1]).toFloat(), (at[0] - origin[0] + view.width).toFloat(), (at[1] - origin[1] + view.height).toFloat())
916
+ val density = view.resources.displayMetrics.density
917
+ var progress = 0f
918
+ val paint = Paint(Paint.ANTI_ALIAS_FLAG)
919
+ val colors = intArrayOf(Color.rgb(59, 130, 246), Color.rgb(153, 246, 228), Color.rgb(230, 196, 140), Color.WHITE)
920
+ val decoration = object : Drawable() {
921
+ override fun draw(canvas: Canvas) {
922
+ for (index in 0 until 52) {
923
+ val side = if (index % 2 == 0) -1f else 1f
924
+ val spread = (index * 37 % 101) / 100f
925
+ val t = ((progress * 3f - (index % 7) * .018f) / (2.2f + spread * .45f)).coerceIn(0f, 1f)
926
+ if (t <= 0 || t >= 1) continue
927
+ val startX = frame.centerX() + side * frame.width() * .42f
928
+ val startY = frame.top + 28 * density
929
+ val endX = frame.centerX() + side * (35 * density + spread * root.width * .7f)
930
+ val endY = root.height + 30 * density
931
+ val x = startX + (endX - startX) * t
932
+ val y = startY + (endY - startY) * t * t - (70 + spread * 100) * density * 4 * t * (1 - t)
933
+ val saved = canvas.save()
934
+ canvas.translate(x, y)
935
+ canvas.rotate(side * (index * 30f + t * (400 + spread * 600)))
936
+ paint.shader = null
937
+ paint.color = colors[index % colors.size]
938
+ paint.alpha = (255 * min(1f, min(t / .04f, (1 - t) / .3f))).roundToInt()
939
+ canvas.drawRoundRect(RectF(-3 * density, -5 * density, (if (index % 3 == 0) 1 else 4) * density, 6 * density), density, density, paint)
940
+ canvas.restoreToCount(saved)
941
+ }
942
+ val light = (progress * 3f - .55f) / 1.15f
943
+ if (light > 0 && light < 1) {
944
+ val saved = canvas.save()
945
+ val mask = Path().apply { addRoundRect(frame, radius, radius, Path.Direction.CW) }
946
+ canvas.clipPath(mask)
947
+ val x = frame.left - frame.width() + light * frame.width() * 3
948
+ paint.alpha = 255
949
+ paint.shader = LinearGradient(x - frame.width() * .25f, frame.top, x + frame.width() * .25f, frame.bottom, intArrayOf(Color.TRANSPARENT, 0x33ffffff, Color.TRANSPARENT), null, Shader.TileMode.CLAMP)
950
+ canvas.drawRect(frame, paint)
951
+ paint.shader = null
952
+ canvas.restoreToCount(saved)
953
+ }
954
+ }
955
+ override fun setAlpha(alpha: Int) {}
956
+ override fun setColorFilter(colorFilter: ColorFilter?) {}
957
+ @Deprecated("Deprecated in Java")
958
+ override fun getOpacity(): Int = PixelFormat.TRANSLUCENT
959
+ }
960
+ decoration.setBounds(0, 0, root.width, root.height)
961
+ this.decoration = decoration
962
+ root.overlay.add(decoration)
963
+ // Classic view animations leave authored transform properties intact.
964
+ val entrance = android.view.animation.AnimationSet(true).apply {
965
+ addAnimation(android.view.animation.ScaleAnimation(.86f, 1f, .86f, 1f, android.view.animation.Animation.RELATIVE_TO_SELF, .5f, android.view.animation.Animation.RELATIVE_TO_SELF, .5f))
966
+ addAnimation(android.view.animation.TranslateAnimation(0f, 0f, 34 * density, 0f))
967
+ addAnimation(android.view.animation.RotateAnimation(-2.6f, 0f, android.view.animation.Animation.RELATIVE_TO_SELF, .5f, android.view.animation.Animation.RELATIVE_TO_SELF, .5f))
968
+ duration = 950
969
+ interpolator = android.view.animation.OvershootInterpolator(.65f)
970
+ }
971
+ view.startAnimation(entrance)
972
+ val clock = ValueAnimator.ofFloat(0f, 1f)
973
+ animator = clock
974
+ clock.apply {
975
+ duration = 3000
976
+ interpolator = LinearInterpolator()
977
+ addUpdateListener {
978
+ if (!ValueAnimator.areAnimatorsEnabled()) { stop(); return@addUpdateListener }
979
+ progress = it.animatedValue as Float
980
+ decoration.invalidateSelf()
981
+ }
982
+ addListener(object : android.animation.AnimatorListenerAdapter() {
983
+ override fun onAnimationEnd(animation: android.animation.Animator) { stop() }
984
+ })
985
+ start()
986
+ }
987
+ }
988
+ }
989
+
868
990
  /** A deterministic flex-shaped container shared by normal views and scroll content. */
869
991
  private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
992
+ private var celebration: CraftNativeCelebration? = null
993
+ fun configureCelebration(raw: JSONObject?, radius: Float) {
994
+ if (raw != null && celebration == null) celebration = CraftNativeCelebration(this)
995
+ celebration?.configure(raw, radius)
996
+ }
997
+ override fun onDetachedFromWindow() {
998
+ celebration?.stop()
999
+ super.onDetachedFromWindow()
1000
+ }
1001
+ override fun onVisibilityChanged(changedView: View, visibility: Int) {
1002
+ super.onVisibilityChanged(changedView, visibility)
1003
+ if (visibility != View.VISIBLE) celebration?.stop()
1004
+ }
1005
+ override fun onWindowVisibilityChanged(visibility: Int) {
1006
+ super.onWindowVisibilityChanged(visibility)
1007
+ if (visibility != View.VISIBLE) celebration?.stop()
1008
+ }
870
1009
  var onLayoutChanged: (() -> Unit)? = null
871
1010
  private val styles = mutableMapOf<View, CraftNativeLayoutStyle>()
872
1011
  private var wrap = false
@@ -1769,6 +1908,7 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
1769
1908
  }
1770
1909
 
1771
1910
  override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
1911
+ celebration?.layout()
1772
1912
  widthDefinite = true
1773
1913
  if (grid) {
1774
1914
  val layout = gridLayout(width, height)
@@ -3631,6 +3771,34 @@ private class CraftNativeSideBorderDrawable(
3631
3771
  override fun getOpacity(): Int = PixelFormat.TRANSLUCENT
3632
3772
  }
3633
3773
 
3774
+ /** Protects only the native three-button region; content keeps its authored background. */
3775
+ private class CraftNativeNavigationBarProtection : Drawable() {
3776
+ private val paint = Paint()
3777
+ private var left = 0
3778
+ private var right = 0
3779
+ private var bottom = 0
3780
+
3781
+ fun update(color: Int, left: Int, right: Int, bottom: Int) {
3782
+ if (paint.color == color && this.left == left && this.right == right && this.bottom == bottom) return
3783
+ paint.color = color
3784
+ this.left = left
3785
+ this.right = right
3786
+ this.bottom = bottom
3787
+ invalidateSelf()
3788
+ }
3789
+
3790
+ override fun draw(canvas: Canvas) {
3791
+ if (left > 0) canvas.drawRect(bounds.left.toFloat(), bounds.top.toFloat(), (bounds.left + left).toFloat(), bounds.bottom.toFloat(), paint)
3792
+ if (right > 0) canvas.drawRect((bounds.right - right).toFloat(), bounds.top.toFloat(), bounds.right.toFloat(), bounds.bottom.toFloat(), paint)
3793
+ if (bottom > 0) canvas.drawRect(bounds.left.toFloat(), (bounds.bottom - bottom).toFloat(), bounds.right.toFloat(), bounds.bottom.toFloat(), paint)
3794
+ }
3795
+
3796
+ override fun setAlpha(alpha: Int) { paint.alpha = alpha; invalidateSelf() }
3797
+ override fun setColorFilter(filter: ColorFilter?) { paint.colorFilter = filter; invalidateSelf() }
3798
+ @Suppress("DEPRECATION")
3799
+ override fun getOpacity(): Int = PixelFormat.TRANSLUCENT
3800
+ }
3801
+
3634
3802
  /** WebView-free host for a route bundle compiled by `stx native compile`. */
3635
3803
  class MainActivity : AppCompatActivity() {
3636
3804
  private class Screen(
@@ -3805,6 +3973,8 @@ class MainActivity : AppCompatActivity() {
3805
3973
  private var initialDeepLinkClaimed = false
3806
3974
  private var defaultTextColor = Color.WHITE
3807
3975
  private var windowSafeInsets = intArrayOf(0, 0, 0, 0)
3976
+ private var navigationButtonInsets = intArrayOf(0, 0, 0)
3977
+ private val navigationBarProtection = CraftNativeNavigationBarProtection()
3808
3978
  private var useMessagePorts = false
3809
3979
  private val imageExecutor = Executors.newFixedThreadPool(2)
3810
3980
  private val imageCache by lazy { CraftNativeImageCache(File(cacheDir, "craft-native-images")) }
@@ -3844,10 +4014,17 @@ class MainActivity : AppCompatActivity() {
3844
4014
  if (savedInstanceState == null) pendingNotificationTap = notificationTap(intent)
3845
4015
  setContentView(R.layout.activity_main)
3846
4016
  root = findViewById(R.id.native_root)
4017
+ root.foreground = navigationBarProtection
3847
4018
  ViewCompat.setOnApplyWindowInsetsListener(root) { view, insets ->
3848
4019
  val safe = insets.getInsets(WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout())
3849
4020
  val ime = insets.getInsets(WindowInsetsCompat.Type.ime())
3850
4021
  windowSafeInsets = intArrayOf(safe.left, safe.top, safe.right, safe.bottom)
4022
+ val tappable = insets.getInsets(WindowInsetsCompat.Type.tappableElement())
4023
+ val buttons = intArrayOf(tappable.left, tappable.right, tappable.bottom)
4024
+ if (!navigationButtonInsets.contentEquals(buttons)) {
4025
+ navigationButtonInsets = buttons
4026
+ visibleScreen?.let(::applyScreenBackground)
4027
+ }
3851
4028
  keyboardInset = max(0, ime.bottom - safe.bottom)
3852
4029
  updateSafeAreaViews()
3853
4030
  updateKeyboardAvoidingViews()
@@ -4576,8 +4753,22 @@ class MainActivity : AppCompatActivity() {
4576
4753
  // only the visible one may change the colour behind system bars.
4577
4754
  if (visibleScreen === screen) {
4578
4755
  root.setBackgroundColor(background)
4756
+ // Android 15's launcher taskbar can retain system-themed button
4757
+ // colours even when the app requests another icon appearance.
4758
+ // Match that palette only behind tappable navigation buttons.
4759
+ // Gesture navigation and authored screen/status backgrounds stay
4760
+ // edge-to-edge; no global theme or system setting is changed.
4761
+ val protectButtons = Build.VERSION.SDK_INT >= 35 && navigationButtonInsets.any { it > 0 }
4762
+ val systemNight = Resources.getSystem().configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES
4763
+ val navigationBackground = if (protectButtons) {
4764
+ getColor(if (systemNight) android.R.color.system_neutral1_900 else android.R.color.system_neutral1_50)
4765
+ } else background
4766
+ navigationBarProtection.update(navigationBackground,
4767
+ if (protectButtons) navigationButtonInsets[0] else 0,
4768
+ if (protectButtons) navigationButtonInsets[1] else 0,
4769
+ if (protectButtons) navigationButtonInsets[2] else 0)
4579
4770
  WindowCompat.getInsetsController(window, window.decorView)
4580
- .isAppearanceLightNavigationBars = Color.luminance(background) >= 0.5f
4771
+ .isAppearanceLightNavigationBars = Color.luminance(navigationBackground) >= 0.5f
4581
4772
  // Android 15+ draws the root behind transparent system bars.
4582
4773
  // Older releases still paint separate window bar surfaces.
4583
4774
  if (Build.VERSION.SDK_INT < 35) {
@@ -5697,6 +5888,7 @@ class MainActivity : AppCompatActivity() {
5697
5888
  }
5698
5889
  }
5699
5890
  if (control is CraftNativeFlexLayout) {
5891
+ control.configureCelebration(props.optJSONObject("celebration"), ((craftNativeNumber(style.opt("borderRadius")) ?: 0.0) * resources.displayMetrics.density).toFloat())
5700
5892
  control.onLayoutChanged = { if (screen.active) emitLayoutEvents(screen) }
5701
5893
  } else if (control is CraftNativeKeyboardAvoidingView) {
5702
5894
  control.content.onLayoutChanged = { if (screen.active) emitLayoutEvents(screen) }
package/dist/cli.js CHANGED
@@ -3424,7 +3424,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
3424
3424
  `);
3425
3425
  }
3426
3426
  // package.json
3427
- var version = "0.0.234";
3427
+ var version = "0.0.236";
3428
3428
 
3429
3429
  // bin/cli.ts
3430
3430
  var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
package/dist/index.cjs CHANGED
@@ -309,7 +309,7 @@ function craftBinaryNotFoundMessage(triedPath) {
309
309
  `);
310
310
  }
311
311
  // package.json
312
- var version = "0.0.234";
312
+ var version = "0.0.236";
313
313
 
314
314
  // src/sdk-version.ts
315
315
  var SDK_VERSION = version;
package/dist/index.js CHANGED
@@ -57,7 +57,7 @@ function craftBinaryNotFoundMessage(triedPath) {
57
57
  `);
58
58
  }
59
59
  // package.json
60
- var version = "0.0.234";
60
+ var version = "0.0.236";
61
61
 
62
62
  // src/sdk-version.ts
63
63
  var SDK_VERSION = version;
@@ -1834,7 +1834,172 @@ extension UIView {
1834
1834
  }
1835
1835
  }
1836
1836
 
1837
+ /// A finite, opt-in native celebration. Reconciliation with the same replay
1838
+ /// value never restarts it; Reduce Motion always leaves the complete card still.
1839
+ struct CraftNativeCelebrationSpec: Equatable {
1840
+ let replay: Double
1841
+ let enabled: Bool
1842
+
1843
+ init?(_ raw: Any?) {
1844
+ guard let value = raw as? [String: Any],
1845
+ let replay = value["replay"] as? NSNumber,
1846
+ CFGetTypeID(replay) != CFBooleanGetTypeID(),
1847
+ replay.doubleValue.isFinite, replay.doubleValue >= 0 else { return nil }
1848
+ self.replay = replay.doubleValue
1849
+ enabled = value["enabled"] as? Bool != false
1850
+ }
1851
+ }
1852
+
1853
+ final class CraftNativeCelebration {
1854
+ private weak var view: UIView?
1855
+ private var request: CraftNativeCelebrationSpec?
1856
+ private var pending = false
1857
+ private var overlay: CALayer?
1858
+ private var sheen: CALayer?
1859
+ private var cleanup: DispatchWorkItem?
1860
+ private var observers: [NSObjectProtocol] = []
1861
+ private(set) var starts = 0
1862
+ var isRunning: Bool { overlay != nil }
1863
+
1864
+ init(view: UIView) {
1865
+ self.view = view
1866
+ for name in [UIApplication.didEnterBackgroundNotification, UIAccessibility.reduceMotionStatusDidChangeNotification] {
1867
+ observers.append(NotificationCenter.default.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in self?.stop() })
1868
+ }
1869
+ }
1870
+
1871
+ deinit {
1872
+ cleanup?.cancel()
1873
+ overlay?.removeFromSuperlayer()
1874
+ sheen?.removeFromSuperlayer()
1875
+ observers.forEach(NotificationCenter.default.removeObserver)
1876
+ }
1877
+
1878
+ func configure(_ raw: Any?) {
1879
+ let next = CraftNativeCelebrationSpec(raw)
1880
+ guard next != request else { return }
1881
+ stop()
1882
+ request = next
1883
+ pending = next?.enabled == true
1884
+ view?.setNeedsLayout()
1885
+ }
1886
+
1887
+ func stop() {
1888
+ pending = false
1889
+ cleanup?.cancel()
1890
+ cleanup = nil
1891
+ view?.layer.removeAnimation(forKey: "craft.celebration.entrance")
1892
+ overlay?.removeFromSuperlayer()
1893
+ overlay = nil
1894
+ sheen?.removeFromSuperlayer()
1895
+ sheen = nil
1896
+ }
1897
+
1898
+ func layout(reduceMotion: Bool = UIAccessibility.isReduceMotionEnabled) {
1899
+ guard pending, let view, let window = view.window,
1900
+ view.bounds.width > 0, view.bounds.height > 0, !view.isHidden else { return }
1901
+ var ancestor: UIView? = view.superview
1902
+ while let parent = ancestor {
1903
+ if parent.isHidden { return }
1904
+ ancestor = parent.superview
1905
+ }
1906
+ pending = false
1907
+ guard !reduceMotion else { return }
1908
+ starts += 1
1909
+ let frame = view.convert(view.bounds, to: window)
1910
+ let overlay = CALayer()
1911
+ overlay.name = "craft.celebration.confetti"
1912
+ overlay.frame = window.bounds
1913
+ window.layer.addSublayer(overlay)
1914
+ self.overlay = overlay
1915
+
1916
+ // Core Animation owns every frame. No script timers, per-frame bridge
1917
+ // messages, bitmap assets or touch-intercepting overlay views.
1918
+ let colors: [UIColor] = [.init(red: 0.23, green: 0.51, blue: 0.96, alpha: 1), .init(red: 0.6, green: 0.96, blue: 0.89, alpha: 1), .init(red: 0.9, green: 0.77, blue: 0.55, alpha: 1), .white]
1919
+ let now = CACurrentMediaTime()
1920
+ for index in 0..<52 {
1921
+ let side: CGFloat = index % 2 == 0 ? -1 : 1
1922
+ let spread = CGFloat((index * 37) % 101) / 100
1923
+ let start = CGPoint(x: frame.midX + side * frame.width * 0.42, y: frame.minY + 28)
1924
+ let end = CGPoint(x: frame.midX + side * (35 + spread * window.bounds.width * 0.7), y: window.bounds.height + 30)
1925
+ let piece = CALayer()
1926
+ piece.bounds = CGRect(x: 0, y: 0, width: index % 3 == 0 ? 4 : 7, height: 11)
1927
+ piece.cornerRadius = 1
1928
+ piece.backgroundColor = colors[index % colors.count].cgColor
1929
+ piece.position = end
1930
+ piece.opacity = 0
1931
+ overlay.addSublayer(piece)
1932
+ let path = CAKeyframeAnimation(keyPath: "position")
1933
+ path.values = [NSValue(cgPoint: start), NSValue(cgPoint: CGPoint(x: start.x + side * (45 + spread * 65), y: max(0, start.y - 70 - spread * 100))), NSValue(cgPoint: end)]
1934
+ path.keyTimes = [0, 0.28, 1]
1935
+ path.calculationMode = .cubic
1936
+ let tumble = CABasicAnimation(keyPath: "transform.rotation")
1937
+ tumble.fromValue = side * Double(index)
1938
+ tumble.toValue = side * (Double(index) + 7 + Double(spread) * 10)
1939
+ let fade = CAKeyframeAnimation(keyPath: "opacity")
1940
+ fade.values = [0, 1, 1, 0]
1941
+ fade.keyTimes = [0, 0.04, 0.7, 1]
1942
+ let group = CAAnimationGroup()
1943
+ group.animations = [path, tumble, fade]
1944
+ group.duration = 2.2 + Double(spread) * 0.45
1945
+ group.beginTime = now + Double(index % 7) * 0.018
1946
+ group.fillMode = .backwards
1947
+ piece.add(group, forKey: "craft.celebration.particle")
1948
+ }
1949
+
1950
+ let entrance = CASpringAnimation(keyPath: "transform")
1951
+ var from = CATransform3DMakeTranslation(0, 34, 0)
1952
+ from = CATransform3DRotate(from, -0.045, 0, 0, 1)
1953
+ from = CATransform3DScale(from, 0.86, 0.86, 1)
1954
+ entrance.fromValue = NSValue(caTransform3D: CATransform3DConcat(view.layer.transform, from))
1955
+ entrance.toValue = NSValue(caTransform3D: view.layer.transform)
1956
+ entrance.damping = 19
1957
+ entrance.stiffness = 160
1958
+ entrance.mass = 1
1959
+ entrance.duration = 0.95
1960
+ view.layer.add(entrance, forKey: "craft.celebration.entrance")
1961
+
1962
+ let sheen = CAGradientLayer()
1963
+ sheen.name = "craft.celebration.sheen"
1964
+ sheen.frame = view.bounds
1965
+ sheen.colors = [UIColor.clear.cgColor, UIColor.white.withAlphaComponent(0.2).cgColor, UIColor.clear.cgColor]
1966
+ sheen.locations = [0.25, 0.5, 0.75]
1967
+ sheen.startPoint = CGPoint(x: 0, y: 0.3)
1968
+ sheen.endPoint = CGPoint(x: 1, y: 0.7)
1969
+ sheen.opacity = 0
1970
+ let mask = CAShapeLayer()
1971
+ mask.path = UIBezierPath(roundedRect: view.bounds, cornerRadius: view.layer.cornerRadius).cgPath
1972
+ sheen.mask = mask
1973
+ view.layer.addSublayer(sheen)
1974
+ self.sheen = sheen
1975
+ let sweep = CABasicAnimation(keyPath: "transform.translation.x")
1976
+ sweep.fromValue = -view.bounds.width
1977
+ sweep.toValue = view.bounds.width
1978
+ let light = CAKeyframeAnimation(keyPath: "opacity")
1979
+ light.values = [0, 1, 1, 0]
1980
+ light.keyTimes = [0, 0.15, 0.8, 1]
1981
+ let shine = CAAnimationGroup()
1982
+ shine.animations = [sweep, light]
1983
+ shine.duration = 1.15
1984
+ shine.beginTime = now + 0.55
1985
+ sheen.add(shine, forKey: "craft.celebration.sweep")
1986
+ let cleanup = DispatchWorkItem { [weak self] in self?.stop() }
1987
+ self.cleanup = cleanup
1988
+ DispatchQueue.main.asyncAfter(deadline: .now() + 3, execute: cleanup)
1989
+ }
1990
+ }
1991
+
1837
1992
  private class CraftNativeFlowView: UIStackView {
1993
+ var celebration: CraftNativeCelebration?
1994
+ func configureCelebration(_ raw: Any?) {
1995
+ if raw != nil && celebration == nil { celebration = CraftNativeCelebration(view: self) }
1996
+ celebration?.configure(raw)
1997
+ }
1998
+ override func didMoveToWindow() {
1999
+ super.didMoveToWindow()
2000
+ if window == nil { celebration?.stop() }
2001
+ else { setNeedsLayout() }
2002
+ }
1838
2003
  var pressActiveOpacity: CGFloat?
1839
2004
  var pressBaseOpacity: CGFloat = 1
1840
2005
  var pressUnderlayColor: UIColor?
@@ -2051,6 +2216,7 @@ private class CraftNativeFlowView: UIStackView {
2051
2216
  override func layoutSubviews() {
2052
2217
  super.layoutSubviews()
2053
2218
  craftApplyCornerRadius()
2219
+ celebration?.layout()
2054
2220
  let scale = window?.screen.scale ?? traitCollection.displayScale
2055
2221
  let result = layout(width: bounds.width, height: bounds.height, maxWidth: bounds.width, maxHeight: bounds.height, place: true)
2056
2222
  var moved = false
@@ -4134,6 +4300,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4134
4300
 
4135
4301
  override func viewWillDisappear(_ animated: Bool) {
4136
4302
  super.viewWillDisappear(animated)
4303
+ stopCelebrations(in: view)
4137
4304
  // The accessory belongs to the visible native screen. Clear the
4138
4305
  // shared shell state before a hybrid/web route appears, otherwise a
4139
4306
  // destination from the outgoing screen remains tappable on top of it.
@@ -4188,6 +4355,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4188
4355
  /// `SCREEN_FOCUS { focused: true }`, and going away is `focused: false`.
4189
4356
  func screenVisibilityChanged(_ visible: Bool) {
4190
4357
  guard !runtimeDisposed else { return }
4358
+ if !visible, isViewLoaded { stopCelebrations(in: view) }
4191
4359
  // Hidden tab stacks receive no UIKit appearance callback. A cancelled
4192
4360
  // preview also restores a screen that never lost focus, so restore its
4193
4361
  // chrome even when no new SCREEN_FOCUS event is needed.
@@ -6217,9 +6385,15 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6217
6385
  if disabled { result.isUserInteractionEnabled = false }
6218
6386
  }
6219
6387
  applyAccessibility(props, type: type, to: result)
6388
+ (result as? CraftNativeFlowView)?.configureCelebration(props["celebration"])
6220
6389
  return current
6221
6390
  }
6222
6391
 
6392
+ private func stopCelebrations(in root: UIView) {
6393
+ (root as? CraftNativeFlowView)?.celebration?.stop()
6394
+ root.subviews.forEach { stopCelebrations(in: $0) }
6395
+ }
6396
+
6223
6397
  private func makeView(_ type: String, props: [String: Any]) -> UIView {
6224
6398
  switch type {
6225
6399
  case "Text":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "craft-native",
3
- "version": "0.0.234",
3
+ "version": "0.0.236",
4
4
  "type": "module",
5
5
  "description": "Build desktop apps with web languages - TypeScript SDK for Craft",
6
6
  "author": "Chris Breuer <chris@stacksjs.org>",