craft-native 0.0.235 → 0.0.237

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.
@@ -43,6 +43,7 @@ import kotlin.math.roundToInt
43
43
  private const val CRAFT_NATIVE_FETCH_MAX_RESPONSE_BYTES = 8 * 1024 * 1024
44
44
  private const val CRAFT_NATIVE_FETCH_TIMEOUT_MS = 30_000
45
45
  private const val CRAFT_NATIVE_FILE_PICK_REQUEST = 4101
46
+ private const val CRAFT_NATIVE_NOTIFICATION_PERMISSION_REQUEST = 4102
46
47
  private const val CRAFT_NATIVE_FILE_DEFAULT_MAX_PIXELS = 2048
47
48
  private const val CRAFT_NATIVE_FILE_LARGEST_MAX_PIXELS = 8192
48
49
  private const val CRAFT_NATIVE_FILE_DEFAULT_QUALITY = 0.85
@@ -88,6 +89,7 @@ internal class CraftNativeCapabilities(
88
89
  private val fetchConnections = ConcurrentHashMap<String, HttpURLConnection>()
89
90
  private val fileRoot by lazy { File(context.cacheDir, "craft-files").apply { mkdirs() } }
90
91
  private var pendingFilePick: PendingFilePick? = null
92
+ private var pendingNotificationPermission: ((Boolean) -> Unit)? = null
91
93
  private var database: SQLiteDatabase? = null
92
94
  private val biometricPrompts = ConcurrentHashMap<String, BiometricPrompt>()
93
95
 
@@ -891,7 +893,16 @@ internal class CraftNativeCapabilities(
891
893
  return
892
894
  }
893
895
  when (method) {
894
- "schedule" -> complete(requestToken, schedule(args.optJSONObject(0)), completion)
896
+ "schedule" -> {
897
+ val notification = args.optJSONObject(0)
898
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
899
+ val host = activity
900
+ if (host == null) { complete(requestToken, failure("PERMISSION_DENIED", "Notification permission must be requested while the app is open"), completion); return }
901
+ if (pendingNotificationPermission != null) { complete(requestToken, failure("BUSY", "A notification permission request is already open"), completion); return }
902
+ pendingNotificationPermission = { granted -> complete(requestToken, if (granted) schedule(notification) else failure("PERMISSION_DENIED", "Notification permission denied"), completion) }
903
+ mainHandler.post { androidx.core.app.ActivityCompat.requestPermissions(host, arrayOf(Manifest.permission.POST_NOTIFICATIONS), CRAFT_NATIVE_NOTIFICATION_PERMISSION_REQUEST) }
904
+ } else complete(requestToken, schedule(notification), completion)
905
+ }
895
906
  "cancel" -> {
896
907
  val id = validKey(args.opt(0))
897
908
  complete(requestToken, if (id == null) failure("INVALID_ARGUMENT", "Notifications.cancel needs an id")
@@ -909,6 +920,13 @@ internal class CraftNativeCapabilities(
909
920
  }
910
921
  }
911
922
 
923
+ fun onNotificationPermissions(requestCode: Int) {
924
+ if (requestCode != CRAFT_NATIVE_NOTIFICATION_PERMISSION_REQUEST) return
925
+ val complete = pendingNotificationPermission ?: return
926
+ pendingNotificationPermission = null
927
+ complete(Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED)
928
+ }
929
+
912
930
  private fun schedule(value: JSONObject?): CraftNativeCapabilityResult {
913
931
  val title = value?.optString("title").orEmpty()
914
932
  if (title.isEmpty()) return failure("INVALID_ARGUMENT", "Notifications.schedule needs a title")
@@ -600,19 +600,43 @@ private class CraftNativeSvgView(context: Context) : View(context) {
600
600
  /** The Android host for the small, host-neutral native trail map contract. */
601
601
  private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMapReadyCallback {
602
602
  private val mapView = MapView(context)
603
+ private val scaleView = LinearLayout(context).apply {
604
+ orientation = LinearLayout.VERTICAL
605
+ setPadding(4, 2, 4, 2)
606
+ background = GradientDrawable().apply { setColor(Color.argb(230, 255, 255, 255)); cornerRadius = 4f }
607
+ importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
608
+ }
609
+ private val scaleLabel = TextView(context).apply { textSize = 11f; setTextColor(Color.rgb(37, 51, 33)) }
610
+ private val scaleLine = View(context).apply { setBackgroundColor(Color.rgb(37, 51, 33)) }
603
611
  private var googleMap: GoogleMap? = null
612
+ private var tileOverlay: com.google.android.gms.maps.model.TileOverlay? = null
613
+ private var tileTemplate: String? = null
614
+ private val tileAttribution = TextView(context).apply { textSize = 9f; setTextColor(Color.DKGRAY); setBackgroundColor(Color.argb(220, 255, 255, 255)); maxLines = 2; visibility = View.GONE }
604
615
  private var pendingProps = JSONObject()
605
616
  private var pendingChildren = JSONArray()
606
617
  private val markers = mutableMapOf<String, Marker>()
607
618
  private val polylines = mutableMapOf<String, Polyline>()
608
619
  private val markerHandlers = mutableMapOf<String, String>()
609
620
  private var onMarkerTap: ((String) -> Unit)? = null
621
+ var onMapPress: ((LatLng) -> Unit)? = null
622
+ var onRegionChangeComplete: ((JSONObject) -> Unit)? = null
623
+ private var userChangingCamera = false
624
+ private var markerBearings = mutableMapOf<String, Float>()
625
+ private var directionIcon: com.google.android.gms.maps.model.BitmapDescriptor? = null
610
626
  private var lifecycleStarted = false
611
627
  private var destroyed = false
612
628
  private var initialCameraKey: String? = null
613
629
 
614
630
  init {
615
631
  addView(mapView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
632
+ scaleView.addView(scaleLabel)
633
+ scaleView.addView(scaleLine, LinearLayout.LayoutParams(1, 2))
634
+ addView(scaleView, LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT, Gravity.BOTTOM or Gravity.START).apply {
635
+ leftMargin = (8 * resources.displayMetrics.density).roundToInt()
636
+ bottomMargin = (24 * resources.displayMetrics.density).roundToInt()
637
+ })
638
+ scaleView.visibility = View.GONE
639
+ addView(tileAttribution, LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT, Gravity.BOTTOM or Gravity.END).apply { rightMargin = (8 * resources.displayMetrics.density).roundToInt(); bottomMargin = (8 * resources.displayMetrics.density).roundToInt() })
616
640
  mapView.onCreate(null)
617
641
  mapView.getMapAsync(this)
618
642
  }
@@ -620,6 +644,24 @@ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMap
620
644
  override fun onMapReady(map: GoogleMap) {
621
645
  if (destroyed) return
622
646
  googleMap = map
647
+ map.setOnMapClickListener { point -> onMapPress?.invoke(point) }
648
+ map.setOnCameraMoveStartedListener { reason ->
649
+ if (reason == GoogleMap.OnCameraMoveStartedListener.REASON_GESTURE) userChangingCamera = true
650
+ }
651
+ map.setOnCameraMoveListener { updateScale() }
652
+ map.setOnCameraIdleListener {
653
+ updateScale()
654
+ if (userChangingCamera) {
655
+ userChangingCamera = false
656
+ val bounds = map.projection.visibleRegion.latLngBounds
657
+ val position = map.cameraPosition
658
+ val longitudeDelta = (bounds.northeast.longitude - bounds.southwest.longitude + 360) % 360
659
+ onRegionChangeComplete?.invoke(JSONObject()
660
+ .put("center", JSONObject().put("latitude", position.target.latitude).put("longitude", position.target.longitude))
661
+ .put("span", JSONObject().put("latitudeDelta", bounds.northeast.latitude - bounds.southwest.latitude).put("longitudeDelta", longitudeDelta))
662
+ .put("heading", position.bearing).put("pitch", position.tilt))
663
+ }
664
+ }
623
665
  map.setOnMarkerClickListener { marker ->
624
666
  val id = markers.entries.firstOrNull { it.value == marker }?.key ?: return@setOnMarkerClickListener false
625
667
  onMarkerTap?.invoke(id)
@@ -642,6 +684,10 @@ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMap
642
684
 
643
685
  private fun applyPending() {
644
686
  val map = googleMap ?: return
687
+ applyMapPadding()
688
+ map.uiSettings.isCompassEnabled = craftNativeBoolean(pendingProps.opt("showsCompass"))
689
+ // The toolbar is replaced by authored actions; the scale uses the native projection.
690
+ map.uiSettings.isMapToolbarEnabled = false
645
691
  map.uiSettings.isScrollGesturesEnabled = craftNativeBoolean(pendingProps.opt("scrollEnabled"), true)
646
692
  map.uiSettings.isZoomGesturesEnabled = craftNativeBoolean(pendingProps.opt("zoomEnabled"), true)
647
693
  map.uiSettings.isRotateGesturesEnabled = craftNativeBoolean(pendingProps.opt("rotateEnabled"))
@@ -651,9 +697,11 @@ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMap
651
697
  "terrain" -> GoogleMap.MAP_TYPE_TERRAIN
652
698
  else -> GoogleMap.MAP_TYPE_NORMAL
653
699
  }
700
+ applyTileOverlay()
654
701
  val showUserLocation = craftNativeBoolean(pendingProps.opt("showsUserLocation")) && hasLocationPermission()
655
702
  runCatching { map.isMyLocationEnabled = showUserLocation }
656
703
  val nextMarkers = mutableMapOf<String, Pair<LatLng, String?>>()
704
+ val nextBearings = mutableMapOf<String, Float>()
657
705
  val nextHandlers = mutableMapOf<String, String>()
658
706
  val nextPolylines = mutableMapOf<String, Triple<List<LatLng>, Int, Float>>()
659
707
  for (index in 0 until pendingChildren.length()) {
@@ -668,6 +716,7 @@ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMap
668
716
  if (type == "mapmarker" || type == "marker") {
669
717
  points.firstOrNull()?.let { point ->
670
718
  nextMarkers[id] = point to (props.opt("title") as? String)
719
+ (props.opt("bearing") as? Number)?.toDouble()?.takeIf { it.isFinite() }?.let { nextBearings[id] = (it % 360).toFloat() }
671
720
  listOf(events.opt("onPress"), events.opt("onMarkerPress"))
672
721
  .firstNotNullOfOrNull(::craftNativeOptionalString)
673
722
  ?.let { nextHandlers[id] = it }
@@ -694,14 +743,19 @@ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMap
694
743
  }
695
744
  }
696
745
  }
746
+ markerBearings = nextBearings
697
747
  markers.keys.filter { nextMarkers[it] == null }.forEach { markers.remove(it)?.remove() }
698
748
  for ((id, value) in nextMarkers) {
699
749
  val marker = markers[id]
700
750
  if (marker != null) {
701
751
  marker.position = value.first
702
752
  marker.title = value.second
753
+ marker.setIcon(if (markerBearings.containsKey(id)) directionMarkerIcon() else BitmapDescriptorFactory.defaultMarker())
754
+ marker.isFlat = markerBearings.containsKey(id)
755
+ marker.rotation = markerBearings[id] ?: 0f
756
+ marker.setAnchor(0.5f, if (markerBearings.containsKey(id)) 0.5f else 1f)
703
757
  } else {
704
- map.addMarker(MarkerOptions().position(value.first).title(value.second).icon(BitmapDescriptorFactory.defaultMarker()))?.let { markers[id] = it }
758
+ map.addMarker(MarkerOptions().position(value.first).title(value.second).icon(if (markerBearings.containsKey(id)) directionMarkerIcon() else BitmapDescriptorFactory.defaultMarker()).flat(markerBearings.containsKey(id)).rotation(markerBearings[id] ?: 0f).anchor(0.5f, if (markerBearings.containsKey(id)) 0.5f else 1f))?.let { markers[id] = it }
705
759
  }
706
760
  }
707
761
  markerHandlers.clear()
@@ -734,17 +788,99 @@ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMap
734
788
  initialCameraKey = key
735
789
  }
736
790
  } else if (craftNativeBoolean(pendingProps.opt("fitToBounds"))) {
737
- val key = "fit:${overlayKey(nextMarkers, nextPolylines)}"
791
+ val key = "fit:${overlayKey(nextMarkers, nextPolylines)}:${pendingProps.optJSONObject("edgePadding")}"
738
792
  if (initialCameraKey != key) {
739
793
  if (fitToBounds()) initialCameraKey = key
740
794
  }
741
795
  } else {
742
796
  initialCameraKey = null
743
797
  }
798
+ applyCameraOrientation()
799
+ updateScale()
800
+ }
801
+
802
+ private fun applyTileOverlay() {
803
+ val map = googleMap ?: return
804
+ val options = pendingProps.optJSONObject("tileOverlay")
805
+ val template = options?.optString("urlTemplate").orEmpty()
806
+ val credit = options?.optString("attribution").orEmpty()
807
+ val next = template.takeIf { it.startsWith("https://") && listOf("{z}", "{x}", "{y}").all { key -> it.contains(key) } && credit.isNotBlank() }
808
+ if (next != tileTemplate) {
809
+ tileOverlay?.remove(); tileOverlay = null; tileTemplate = next
810
+ if (next != null) {
811
+ val provider = object : com.google.android.gms.maps.model.UrlTileProvider(256, 256) {
812
+ override fun getTileUrl(x: Int, y: Int, zoom: Int): java.net.URL? = runCatching { java.net.URL(next.replace("{x}", x.toString()).replace("{y}", y.toString()).replace("{z}", zoom.toString())) }.getOrNull()
813
+ }
814
+ tileOverlay = map.addTileOverlay(com.google.android.gms.maps.model.TileOverlayOptions().tileProvider(provider).zIndex(-1f))
815
+ }
816
+ }
817
+ if (next != null) map.mapType = GoogleMap.MAP_TYPE_NONE
818
+ tileAttribution.text = credit
819
+ tileAttribution.visibility = if (next == null) View.GONE else View.VISIBLE
820
+ }
821
+
822
+ private fun applyMapPadding() {
823
+ val authored = pendingProps.optJSONObject("edgePadding")
824
+ fun inset(name: String): Double = (authored?.opt(name) as? Number)?.toDouble()?.takeIf { it.isFinite() }?.coerceIn(0.0, 10_000.0)?.times(resources.displayMetrics.density) ?: 0.0
825
+ var left = inset("left"); var top = inset("top"); var right = inset("right"); var bottom = inset("bottom")
826
+ val horizontal = left + right
827
+ val vertical = top + bottom
828
+ val availableWidth = (width - 40 * resources.displayMetrics.density).coerceAtLeast(0f).toDouble()
829
+ val availableHeight = (height - 40 * resources.displayMetrics.density).coerceAtLeast(0f).toDouble()
830
+ if (horizontal > availableWidth && horizontal > 0) { left *= availableWidth / horizontal; right *= availableWidth / horizontal }
831
+ if (vertical > availableHeight && vertical > 0) { top *= availableHeight / vertical; bottom *= availableHeight / vertical }
832
+ googleMap?.setPadding(left.roundToInt(), top.roundToInt(), right.roundToInt(), bottom.roundToInt())
833
+ }
834
+
835
+ private fun updateScale() {
836
+ val map = googleMap ?: return
837
+ if (!craftNativeBoolean(pendingProps.opt("showsScale")) || width <= 0 || height <= 0) { scaleView.visibility = View.GONE; return }
838
+ val maximumWidth = (80 * resources.displayMetrics.density).roundToInt()
839
+ val origin = map.projection.fromScreenLocation(android.graphics.Point(width / 2, height / 2))
840
+ val end = map.projection.fromScreenLocation(android.graphics.Point(width / 2 + maximumWidth, height / 2))
841
+ val distance = FloatArray(1)
842
+ android.location.Location.distanceBetween(origin.latitude, origin.longitude, end.latitude, end.longitude, distance)
843
+ val metres = distance[0].toDouble()
844
+ if (!metres.isFinite() || metres <= 0) { scaleView.visibility = View.GONE; return }
845
+ val magnitude = Math.pow(10.0, kotlin.math.floor(kotlin.math.log10(metres)))
846
+ val length = listOf(5.0, 2.0, 1.0).firstOrNull { it * magnitude <= metres }?.times(magnitude) ?: magnitude
847
+ scaleLabel.text = if (length >= 1000) "${length / 1000} km" else "${length.roundToInt()} m"
848
+ scaleLine.layoutParams = LinearLayout.LayoutParams((maximumWidth * length / metres).roundToInt().coerceAtLeast(1), 2)
849
+ scaleView.visibility = View.VISIBLE
850
+ }
851
+
852
+ private fun applyCameraOrientation() {
853
+ val map = googleMap ?: return
854
+ val camera = map.cameraPosition
855
+ val pitch = (pendingProps.opt("cameraPitch") as? Number)?.toDouble()?.takeIf { it.isFinite() }?.coerceIn(0.0, 60.0)?.toFloat() ?: camera.tilt
856
+ val heading = (pendingProps.opt("cameraHeading") as? Number)?.toDouble()?.takeIf { it.isFinite() }?.toFloat() ?: camera.bearing
857
+ if (kotlin.math.abs(camera.tilt - pitch) > 0.01f || kotlin.math.abs(camera.bearing - heading) > 0.01f) {
858
+ map.moveCamera(CameraUpdateFactory.newCameraPosition(com.google.android.gms.maps.model.CameraPosition.Builder(camera).tilt(pitch).bearing(heading).build()))
859
+ }
860
+ }
861
+
862
+ private fun directionMarkerIcon(): com.google.android.gms.maps.model.BitmapDescriptor {
863
+ directionIcon?.let { return it }
864
+ val size = (26 * resources.displayMetrics.density).roundToInt().coerceAtLeast(24)
865
+ val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
866
+ val canvas = Canvas(bitmap)
867
+ val paint = Paint(Paint.ANTI_ALIAS_FLAG)
868
+ paint.color = Color.WHITE
869
+ canvas.drawCircle(size / 2f, size / 2f, size / 2f, paint)
870
+ paint.color = Color.rgb(36, 114, 75)
871
+ canvas.drawCircle(size / 2f, size / 2f, size * 0.43f, paint)
872
+ paint.color = Color.WHITE
873
+ paint.textSize = size * 0.76f
874
+ paint.textAlign = Paint.Align.CENTER
875
+ paint.typeface = Typeface.DEFAULT_BOLD
876
+ canvas.drawText("↑", size / 2f, size / 2f - (paint.ascent() + paint.descent()) / 2, paint)
877
+ return BitmapDescriptorFactory.fromBitmap(bitmap).also { directionIcon = it }
744
878
  }
745
879
 
746
880
  override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) {
747
881
  super.onSizeChanged(width, height, oldWidth, oldHeight)
882
+ applyMapPadding()
883
+ updateScale()
748
884
  // MapView can become ready before its host has a usable viewport. Do
749
885
  // not mark a fit as complete until the camera update can be issued.
750
886
  if (width > 0 && height > 0 && craftNativeBoolean(pendingProps.opt("fitToBounds")) && initialCameraKey == null) {
@@ -754,7 +890,10 @@ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMap
754
890
 
755
891
  private fun moveCamera(bounds: LatLngBounds, padding: Int = 24) {
756
892
  post {
757
- if (width > 0 && height > 0) googleMap?.moveCamera(CameraUpdateFactory.newLatLngBounds(bounds, padding))
893
+ if (width > 0 && height > 0) {
894
+ googleMap?.moveCamera(CameraUpdateFactory.newLatLngBounds(bounds, padding))
895
+ applyCameraOrientation()
896
+ }
758
897
  }
759
898
  }
760
899
 
@@ -780,7 +919,12 @@ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMap
780
919
  polylines.values.flatMap { it.points }.forEach { builder.include(it); count++ }
781
920
  if (count == 0) return false
782
921
  post {
783
- if (width > 0 && height > 0) map.moveCamera(CameraUpdateFactory.newLatLngBounds(builder.build(), 24))
922
+ if (width > 0 && height > 0) {
923
+ applyMapPadding()
924
+ val padding = if (pendingProps.has("edgePadding")) 0 else (24 * resources.displayMetrics.density).roundToInt()
925
+ map.moveCamera(CameraUpdateFactory.newLatLngBounds(builder.build(), padding))
926
+ applyCameraOrientation()
927
+ }
784
928
  }
785
929
  return true
786
930
  }
@@ -811,6 +955,10 @@ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMap
811
955
  lifecycleStarted = false
812
956
  }
813
957
  onMarkerTap = null
958
+ onMapPress = null
959
+ onRegionChangeComplete = null
960
+ directionIcon = null
961
+ tileOverlay?.remove(); tileOverlay = null
814
962
  markerHandlers.clear()
815
963
  markers.values.forEach { it.remove() }
816
964
  markers.clear()
@@ -866,8 +1014,146 @@ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMap
866
1014
  private fun color(value: String): Int = runCatching { craftNativeColor(value) }.getOrDefault(Color.BLUE)
867
1015
  }
868
1016
 
1017
+ /** A finite celebration owned by a View, never by the JavaScript frame loop. */
1018
+ internal class CraftNativeCelebration(private val view: View) {
1019
+ private var request: Pair<Double, Boolean>? = null
1020
+ private var pending = false
1021
+ private var animator: ValueAnimator? = null
1022
+ private var decoration: Drawable? = null
1023
+ private var root: View? = null
1024
+ private var radius = 0f
1025
+ var starts = 0
1026
+ private set
1027
+ val isRunning: Boolean get() = animator != null
1028
+
1029
+ fun configure(raw: JSONObject?, radius: Float) {
1030
+ val replay = craftNativeNumber(raw?.opt("replay"))?.takeIf { it >= 0 }
1031
+ val next = replay?.let { it to (raw?.opt("enabled") != false) }
1032
+ this.radius = radius
1033
+ if (next == request) return
1034
+ stop()
1035
+ request = next
1036
+ pending = next?.second == true
1037
+ view.requestLayout()
1038
+ }
1039
+
1040
+ fun stop() {
1041
+ pending = false
1042
+ animator?.removeAllUpdateListeners()
1043
+ animator?.removeAllListeners()
1044
+ animator?.cancel()
1045
+ animator = null
1046
+ decoration?.let { root?.overlay?.remove(it) }
1047
+ decoration = null
1048
+ root = null
1049
+ view.clearAnimation()
1050
+ }
1051
+
1052
+ fun layout(animationsEnabled: Boolean = ValueAnimator.areAnimatorsEnabled()) {
1053
+ if (!pending || !view.isAttachedToWindow || !view.isShown || view.width <= 0 || view.height <= 0) return
1054
+ pending = false
1055
+ if (!animationsEnabled) return
1056
+ starts += 1
1057
+ val root = view.rootView
1058
+ this.root = root
1059
+ val at = IntArray(2)
1060
+ val origin = IntArray(2)
1061
+ view.getLocationInWindow(at)
1062
+ root.getLocationInWindow(origin)
1063
+ 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())
1064
+ val density = view.resources.displayMetrics.density
1065
+ var progress = 0f
1066
+ val paint = Paint(Paint.ANTI_ALIAS_FLAG)
1067
+ val colors = intArrayOf(Color.rgb(59, 130, 246), Color.rgb(153, 246, 228), Color.rgb(230, 196, 140), Color.WHITE)
1068
+ val decoration = object : Drawable() {
1069
+ override fun draw(canvas: Canvas) {
1070
+ for (index in 0 until 52) {
1071
+ val side = if (index % 2 == 0) -1f else 1f
1072
+ val spread = (index * 37 % 101) / 100f
1073
+ val t = ((progress * 3f - (index % 7) * .018f) / (2.2f + spread * .45f)).coerceIn(0f, 1f)
1074
+ if (t <= 0 || t >= 1) continue
1075
+ val startX = frame.centerX() + side * frame.width() * .42f
1076
+ val startY = frame.top + 28 * density
1077
+ val endX = frame.centerX() + side * (35 * density + spread * root.width * .7f)
1078
+ val endY = root.height + 30 * density
1079
+ val x = startX + (endX - startX) * t
1080
+ val y = startY + (endY - startY) * t * t - (70 + spread * 100) * density * 4 * t * (1 - t)
1081
+ val saved = canvas.save()
1082
+ canvas.translate(x, y)
1083
+ canvas.rotate(side * (index * 30f + t * (400 + spread * 600)))
1084
+ paint.shader = null
1085
+ paint.color = colors[index % colors.size]
1086
+ paint.alpha = (255 * min(1f, min(t / .04f, (1 - t) / .3f))).roundToInt()
1087
+ canvas.drawRoundRect(RectF(-3 * density, -5 * density, (if (index % 3 == 0) 1 else 4) * density, 6 * density), density, density, paint)
1088
+ canvas.restoreToCount(saved)
1089
+ }
1090
+ val light = (progress * 3f - .55f) / 1.15f
1091
+ if (light > 0 && light < 1) {
1092
+ val saved = canvas.save()
1093
+ val mask = Path().apply { addRoundRect(frame, radius, radius, Path.Direction.CW) }
1094
+ canvas.clipPath(mask)
1095
+ val x = frame.left - frame.width() + light * frame.width() * 3
1096
+ paint.alpha = 255
1097
+ 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)
1098
+ canvas.drawRect(frame, paint)
1099
+ paint.shader = null
1100
+ canvas.restoreToCount(saved)
1101
+ }
1102
+ }
1103
+ override fun setAlpha(alpha: Int) {}
1104
+ override fun setColorFilter(colorFilter: ColorFilter?) {}
1105
+ @Deprecated("Deprecated in Java")
1106
+ override fun getOpacity(): Int = PixelFormat.TRANSLUCENT
1107
+ }
1108
+ decoration.setBounds(0, 0, root.width, root.height)
1109
+ this.decoration = decoration
1110
+ root.overlay.add(decoration)
1111
+ // Classic view animations leave authored transform properties intact.
1112
+ val entrance = android.view.animation.AnimationSet(true).apply {
1113
+ 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))
1114
+ addAnimation(android.view.animation.TranslateAnimation(0f, 0f, 34 * density, 0f))
1115
+ addAnimation(android.view.animation.RotateAnimation(-2.6f, 0f, android.view.animation.Animation.RELATIVE_TO_SELF, .5f, android.view.animation.Animation.RELATIVE_TO_SELF, .5f))
1116
+ duration = 950
1117
+ interpolator = android.view.animation.OvershootInterpolator(.65f)
1118
+ }
1119
+ view.startAnimation(entrance)
1120
+ val clock = ValueAnimator.ofFloat(0f, 1f)
1121
+ animator = clock
1122
+ clock.apply {
1123
+ duration = 3000
1124
+ interpolator = LinearInterpolator()
1125
+ addUpdateListener {
1126
+ if (!ValueAnimator.areAnimatorsEnabled()) { stop(); return@addUpdateListener }
1127
+ progress = it.animatedValue as Float
1128
+ decoration.invalidateSelf()
1129
+ }
1130
+ addListener(object : android.animation.AnimatorListenerAdapter() {
1131
+ override fun onAnimationEnd(animation: android.animation.Animator) { stop() }
1132
+ })
1133
+ start()
1134
+ }
1135
+ }
1136
+ }
1137
+
869
1138
  /** A deterministic flex-shaped container shared by normal views and scroll content. */
870
1139
  private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
1140
+ private var celebration: CraftNativeCelebration? = null
1141
+ fun configureCelebration(raw: JSONObject?, radius: Float) {
1142
+ if (raw != null && celebration == null) celebration = CraftNativeCelebration(this)
1143
+ celebration?.configure(raw, radius)
1144
+ }
1145
+ override fun onDetachedFromWindow() {
1146
+ celebration?.stop()
1147
+ super.onDetachedFromWindow()
1148
+ }
1149
+ override fun onVisibilityChanged(changedView: View, visibility: Int) {
1150
+ super.onVisibilityChanged(changedView, visibility)
1151
+ if (visibility != View.VISIBLE) celebration?.stop()
1152
+ }
1153
+ override fun onWindowVisibilityChanged(visibility: Int) {
1154
+ super.onWindowVisibilityChanged(visibility)
1155
+ if (visibility != View.VISIBLE) celebration?.stop()
1156
+ }
871
1157
  var onLayoutChanged: (() -> Unit)? = null
872
1158
  private val styles = mutableMapOf<View, CraftNativeLayoutStyle>()
873
1159
  private var wrap = false
@@ -1770,6 +2056,7 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
1770
2056
  }
1771
2057
 
1772
2058
  override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
2059
+ celebration?.layout()
1773
2060
  widthDefinite = true
1774
2061
  if (grid) {
1775
2062
  val layout = gridLayout(width, height)
@@ -5607,6 +5894,16 @@ class MainActivity : AppCompatActivity() {
5607
5894
  }
5608
5895
  "Map" -> (previous as? CraftNativeMapView ?: CraftNativeMapView(this)).apply {
5609
5896
  val mapMarkerHandler = craftNativeOptionalString(events.opt("onMarkerPress"))
5897
+ onMapPress = { point ->
5898
+ craftNativeOptionalString(events.opt("onPress"))?.let { handler ->
5899
+ send(screen, "EVENT", JSONObject().put("handlerName", handler).put("nativeEvent", JSONObject().put("latitude", point.latitude).put("longitude", point.longitude)))
5900
+ }
5901
+ }
5902
+ onRegionChangeComplete = { region ->
5903
+ craftNativeOptionalString(events.opt("onRegionChangeComplete"))?.let { handler ->
5904
+ send(screen, "EVENT", JSONObject().put("handlerName", handler).put("nativeEvent", region))
5905
+ }
5906
+ }
5610
5907
  configure(props, children) { markerId ->
5611
5908
  (markerHandler(markerId) ?: mapMarkerHandler)?.let { handler ->
5612
5909
  send(screen, "EVENT", JSONObject().put("handlerName", handler).put("nativeEvent", JSONObject().put("id", markerId)))
@@ -5749,6 +6046,7 @@ class MainActivity : AppCompatActivity() {
5749
6046
  }
5750
6047
  }
5751
6048
  if (control is CraftNativeFlexLayout) {
6049
+ control.configureCelebration(props.optJSONObject("celebration"), ((craftNativeNumber(style.opt("borderRadius")) ?: 0.0) * resources.displayMetrics.density).toFloat())
5752
6050
  control.onLayoutChanged = { if (screen.active) emitLayoutEvents(screen) }
5753
6051
  } else if (control is CraftNativeKeyboardAvoidingView) {
5754
6052
  control.content.onLayoutChanged = { if (screen.active) emitLayoutEvents(screen) }
@@ -7013,7 +7311,7 @@ class MainActivity : AppCompatActivity() {
7013
7311
 
7014
7312
  override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
7015
7313
  super.onRequestPermissionsResult(requestCode, permissions, grantResults)
7016
- if (::capabilities.isInitialized) capabilities.onBluetoothPermissions(requestCode)
7314
+ if (::capabilities.isInitialized) { capabilities.onBluetoothPermissions(requestCode); capabilities.onNotificationPermissions(requestCode) }
7017
7315
  if (::services.isInitialized) services.onRequestPermissionsResult(requestCode)
7018
7316
  }
7019
7317
 
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.235";
3427
+ var version = "0.0.237";
3428
3428
 
3429
3429
  // bin/cli.ts
3430
3430
  var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
@@ -491,6 +491,18 @@ export interface MapProps extends BaseProps {
491
491
  region?: MapRegion;
492
492
  /** Initial camera region; applied once, then controlled by subsequent props. */
493
493
  initialRegion?: MapRegion;
494
+ /** A native raster basemap, with required provider attribution. */
495
+ tileOverlay?: {
496
+ urlTemplate: string;
497
+ attribution: string;
498
+ };
499
+ /** Logical points reserved for floating controls when fitting overlays. */
500
+ edgePadding?: {
501
+ top?: number;
502
+ right?: number;
503
+ bottom?: number;
504
+ left?: number;
505
+ };
494
506
  fitToBounds?: boolean;
495
507
  showsUserLocation?: boolean;
496
508
  scrollEnabled?: boolean;
@@ -498,6 +510,25 @@ export interface MapProps extends BaseProps {
498
510
  rotateEnabled?: boolean;
499
511
  /** Map styles implemented consistently by the maintained iOS and Android hosts. */
500
512
  mapType?: 'standard' | 'satellite' | 'hybrid';
513
+ showsCompass?: boolean;
514
+ showsScale?: boolean;
515
+ /** Requested tilt in degrees; the platform may lower it for the current altitude or map type. */
516
+ cameraPitch?: number;
517
+ cameraHeading?: number;
518
+ /** A coordinate tapped on the map, excluding annotation presses. */
519
+ onPress?: (event: {
520
+ nativeEvent: {
521
+ latitude: number;
522
+ longitude: number;
523
+ };
524
+ }) => void;
525
+ /** Settled camera after a person pans, rotates, zooms or tilts the map. */
526
+ onRegionChangeComplete?: (event: {
527
+ nativeEvent: MapRegion & {
528
+ heading: number;
529
+ pitch: number;
530
+ };
531
+ }) => void;
501
532
  onMarkerPress?: (event: {
502
533
  nativeEvent: {
503
534
  id: string;
@@ -513,6 +544,8 @@ export interface MapMarkerProps extends BaseProps {
513
544
  geometry: number[];
514
545
  id: string;
515
546
  title?: string;
547
+ /** A direction arrow in place of the default marker, clockwise from north. */
548
+ bearing?: number;
516
549
  onPress?: (event: {
517
550
  nativeEvent: {
518
551
  id: string;
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.235";
312
+ var version = "0.0.237";
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.235";
60
+ var version = "0.0.237";
61
61
 
62
62
  // src/sdk-version.ts
63
63
  var SDK_VERSION = version;
@@ -0,0 +1,11 @@
1
+ export interface CraftAppShortcut {
2
+ id: string;
3
+ title: string;
4
+ subtitle?: string;
5
+ symbol: string;
6
+ url: string;
7
+ /** Each phrase includes exactly one `{app}` token for Siri's app name. */
8
+ phrases: string[];
9
+ }
10
+ /** Build-owned App Intents survive shell restoration and need no app Swift. */
11
+ export declare function appShortcutsSwift(shortcuts?: CraftAppShortcut[], schemes?: string[]): string;
@@ -3,12 +3,14 @@
3
3
  *
4
4
  * Generates native iOS apps from web content using WKWebView.
5
5
  */
6
+ import { type CraftAppShortcut } from './app-shortcuts.js';
6
7
  /** Package custom fonts and their licenses; removed fonts cannot linger in the app. */
7
8
  export declare function syncNativeFonts(fonts: CraftConfig['nativeFonts'], output: string): string[];
8
9
  /** This package's version, which the app names in its user agent (`Craft/<version>`). */
9
10
  export declare function resolveCraftVersion(startDir?: string): string;
10
11
  export type CraftAppearance = 'light' | 'dark' | 'system';
11
12
  export interface CraftConfig {
13
+ appShortcuts?: CraftAppShortcut[];
12
14
  /** Bundled font files keyed by their PostScript font name, used as native fontFamily. */
13
15
  nativeFonts?: Record<string, {
14
16
  src: string;
@@ -4,6 +4,60 @@ import { cpSync, existsSync, mkdirSync as mkdirSync2, readFileSync as readFileSy
4
4
  import { dirname as dirname2, extname, join as join3, resolve, sep as sep2 } from "path";
5
5
  var {$ } = globalThis.Bun;
6
6
 
7
+ // ../ios/src/app-shortcuts.ts
8
+ function literal(value) {
9
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t")}"`;
10
+ }
11
+ function appShortcutsSwift(shortcuts = [], schemes = []) {
12
+ if (shortcuts.length > 10)
13
+ throw new Error("iOS supports at most ten app shortcuts");
14
+ const ids = new Set;
15
+ for (const shortcut of shortcuts) {
16
+ if (!/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(shortcut.id) || ids.has(shortcut.id))
17
+ throw new Error(`Invalid or duplicate app shortcut id: ${shortcut.id}`);
18
+ ids.add(shortcut.id);
19
+ const scheme = /^([a-z][a-z0-9+.-]*):\/\//i.exec(shortcut.url)?.[1];
20
+ if (!scheme || !schemes.includes(scheme) || /[\s\\]/.test(shortcut.url))
21
+ throw new Error(`App shortcut ${shortcut.id} needs a registered app URL scheme`);
22
+ if (!shortcut.title?.trim() || !shortcut.symbol?.trim() || !shortcut.phrases?.length || shortcut.phrases.some((phrase) => phrase.split("{app}").length !== 2))
23
+ throw new Error(`App shortcut ${shortcut.id} needs a title, symbol and phrases with exactly one {app}`);
24
+ }
25
+ const name = (id) => `CraftShortcut${id.split("-").map((part) => part[0].toUpperCase() + part.slice(1)).join("")}Intent`;
26
+ const intents = shortcuts.map((shortcut) => `@available(iOS 16.4, *)
27
+ struct ${name(shortcut.id)}: AppIntent {
28
+ static var title: LocalizedStringResource = ${literal(shortcut.title)}
29
+ static var description = IntentDescription(${literal(shortcut.subtitle ?? shortcut.title)})
30
+ static var openAppWhenRun: Bool = true
31
+ @MainActor
32
+ func perform() async throws -> some IntentResult {
33
+ if let url = URL(string: ${literal(shortcut.url)}) { await UIApplication.shared.open(url) }
34
+ return .result()
35
+ }
36
+ }`).join(`
37
+
38
+ `);
39
+ const entries = shortcuts.map((shortcut) => ` AppShortcut(
40
+ intent: ${name(shortcut.id)}(),
41
+ phrases: [${shortcut.phrases.map((phrase) => literal(phrase).replace("{app}", "\\(.applicationName)")).join(", ")}],
42
+ shortTitle: ${literal(shortcut.title)},
43
+ systemImageName: ${literal(shortcut.symbol)}
44
+ )`).join(`
45
+ `);
46
+ return `// Generated by Craft from appShortcuts. Restored from the package at build time.
47
+ import AppIntents
48
+ import UIKit
49
+
50
+ ${intents}
51
+ ${shortcuts.length ? `
52
+ @available(iOS 16.4, *)
53
+ struct CraftConfiguredAppShortcuts: AppShortcutsProvider {
54
+ static var appShortcuts: [AppShortcut] {
55
+ ${entries}
56
+ }
57
+ }
58
+ ` : ""}`;
59
+ }
60
+
7
61
  // ../ios/src/source-audit.ts
8
62
  import { readdirSync } from "fs";
9
63
  import { join, relative, sep } from "path";
@@ -1073,11 +1127,13 @@ function writeShell(output, config) {
1073
1127
  if (!config.appName || !/^[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+$/.test(config.bundleId))
1074
1128
  throw new Error("The iOS shell needs an appName and a valid bundleId in craft.config.json.");
1075
1129
  const name = productName(config.appName);
1130
+ const shortcutSource = appShortcutsSwift(config.appShortcuts, config.urlSchemes);
1076
1131
  mkdirSync2(join3(output, "Sources"), { recursive: true });
1077
1132
  mkdirSync2(join3(output, "Shared"), { recursive: true });
1078
1133
  const swiftTemplate = readFileSync2(join3(TEMPLATES_DIR, "CraftApp.swift"), "utf-8");
1079
1134
  const swiftSource = swiftTemplate.replace(/CraftApp/g, `${name}App`).replace(/\{\{BUNDLE_ID\}\}/g, config.bundleId).replace(/\{\{CRAFT_VERSION\}\}/g, CRAFT_VERSION);
1080
1135
  writeFileSync2(join3(output, "Sources", `${name}App.swift`), swiftSource);
1136
+ writeFileSync2(join3(output, "Sources", "CraftAppShortcuts.swift"), shortcutSource);
1081
1137
  cpSync(join3(TEMPLATES_DIR, "CraftNativeScreen.swift"), join3(output, "Sources", "CraftNativeScreen.swift"));
1082
1138
  cpSync(join3(TEMPLATES_DIR, "CraftNativeFlatList.swift"), join3(output, "Sources", "CraftNativeFlatList.swift"));
1083
1139
  cpSync(join3(TEMPLATES_DIR, "CraftNativeActions.swift"), join3(output, "Sources", "CraftNativeActions.swift"));
@@ -205,6 +205,12 @@ final class CraftAppDelegate: NSObject, UIApplicationDelegate, UNUserNotificatio
205
205
  // push, a refresh, a route recording's location) draws nothing, and
206
206
  // its events still need someone to hear them.
207
207
  CraftHybrid.shared.configure(config)
208
+ if let shortcuts = config.appShortcuts {
209
+ application.shortcutItems = shortcuts.prefix(4).map { shortcut in
210
+ UIApplicationShortcutItem(type: shortcut.id, localizedTitle: shortcut.title, localizedSubtitle: shortcut.subtitle,
211
+ icon: UIApplicationShortcutIcon(systemImageName: shortcut.symbol), userInfo: ["url": shortcut.url as NSString])
212
+ }
213
+ }
208
214
  if let shortcut = launchOptions?[.shortcutItem] as? UIApplicationShortcutItem {
209
215
  CraftEventManager.shared.handleShortcut(shortcut)
210
216
  // Returning false tells UIKit the launch-time item was handled and
@@ -1399,7 +1405,17 @@ extension CraftConfig {
1399
1405
  }
1400
1406
 
1401
1407
  // MARK: - Configuration
1408
+ struct CraftConfiguredShortcut: Codable {
1409
+ var id: String
1410
+ var title: String
1411
+ var subtitle: String?
1412
+ var symbol: String
1413
+ var url: String
1414
+ var phrases: [String]
1415
+ }
1416
+
1402
1417
  struct CraftConfig: Codable {
1418
+ var appShortcuts: [CraftConfiguredShortcut]? = nil
1403
1419
  var appName: String = "Craft App"
1404
1420
  var bundleId: String = "com.craft.app"
1405
1421
  /// Opt-in UIKit + JavaScriptCore screen; existing apps keep WKWebView.
@@ -1161,8 +1161,29 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1161
1161
  private var overlayWidths: [ObjectIdentifier: CGFloat] = [:]
1162
1162
  private var initialCameraKey: String?
1163
1163
  private var pendingFitKey: String?
1164
+ private var pendingRegion: MKCoordinateRegion?
1164
1165
  private(set) var markerHandlers: [String: String] = [:]
1165
1166
  var onMarkerTap: ((String) -> Void)?
1167
+ var onMapPress: ((CLLocationCoordinate2D) -> Void)?
1168
+ var onRegionChangeComplete: ((MKCoordinateRegion, Double, Double) -> Void)?
1169
+ private var userChangingCamera = false
1170
+ private var authoredPitch: Double?
1171
+ private var authoredHeading: Double?
1172
+ private var edgePadding = UIEdgeInsets(top: 24, left: 24, bottom: 24, right: 24)
1173
+ private var markerBearings: [String: Double] = [:]
1174
+ private var tileOverlay: MKTileOverlay?
1175
+ private var tileTemplate: String?
1176
+ private lazy var tileAttribution: UILabel = {
1177
+ let label = UILabel()
1178
+ label.font = .systemFont(ofSize: 9)
1179
+ label.textColor = .darkGray
1180
+ label.backgroundColor = UIColor.white.withAlphaComponent(0.85)
1181
+ label.translatesAutoresizingMaskIntoConstraints = false
1182
+ label.numberOfLines = 2
1183
+ addSubview(label)
1184
+ NSLayoutConstraint.activate([label.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8), label.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -8), label.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: 48)])
1185
+ return label
1186
+ }()
1166
1187
 
1167
1188
  override init(frame: CGRect) {
1168
1189
  super.init(frame: frame)
@@ -1170,6 +1191,9 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1170
1191
  showsCompass = false
1171
1192
  showsScale = false
1172
1193
  isRotateEnabled = false
1194
+ let tap = UITapGestureRecognizer(target: self, action: #selector(pressedMap(_:)))
1195
+ tap.cancelsTouchesInView = false
1196
+ addGestureRecognizer(tap)
1173
1197
  }
1174
1198
 
1175
1199
  required init?(coder: NSCoder) {
@@ -1183,6 +1207,18 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1183
1207
  isZoomEnabled = props["zoomEnabled"] as? Bool ?? true
1184
1208
  isRotateEnabled = props["rotateEnabled"] as? Bool ?? false
1185
1209
  mapType = Self.mapType(props["mapType"] as? String)
1210
+ configureTiles(props["tileOverlay"] as? [String: Any])
1211
+ showsCompass = props["showsCompass"] as? Bool ?? false
1212
+ showsScale = props["showsScale"] as? Bool ?? false
1213
+ authoredPitch = Self.numeric(props["cameraPitch"])
1214
+ authoredHeading = Self.numeric(props["cameraHeading"])
1215
+ let padding = props["edgePadding"] as? [String: Any]
1216
+ func inset(_ name: String) -> CGFloat {
1217
+ guard let value = Self.numeric(padding?[name]), value.isFinite else { return 24 }
1218
+ return CGFloat(min(10_000, max(0, value)))
1219
+ }
1220
+ edgePadding = UIEdgeInsets(top: inset("top"), left: inset("left"), bottom: inset("bottom"), right: inset("right"))
1221
+ var nextBearings: [String: Double] = [:]
1186
1222
  // Showing the user dot must not trigger a permission prompt from a
1187
1223
  // renderer pass. The app's existing location capability owns that
1188
1224
  // prompt; a map only opts in after authorization is already granted.
@@ -1203,6 +1239,9 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1203
1239
  if type == "mapmarker" || type == "marker" {
1204
1240
  guard let point = points.first else { continue }
1205
1241
  nextMarkers[id] = (point, childProps["title"] as? String)
1242
+ if let bearing = Self.numeric(childProps["bearing"]), bearing.isFinite {
1243
+ nextBearings[id] = bearing.truncatingRemainder(dividingBy: 360)
1244
+ }
1206
1245
  if let handler = nonEmptyHandler(childEvents["onPress"])
1207
1246
  ?? nonEmptyHandler(childEvents["onMarkerPress"]) {
1208
1247
  nextHandlers[id] = handler
@@ -1216,12 +1255,14 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1216
1255
  }
1217
1256
  }
1218
1257
 
1258
+ markerBearings = nextBearings
1219
1259
  let staleMarkers = annotationsByID.keys.filter { nextMarkers[$0] == nil }
1220
1260
  removeAnnotations(staleMarkers.compactMap { annotationsByID.removeValue(forKey: $0) })
1221
1261
  for (id, value) in nextMarkers {
1222
1262
  if let annotation = annotationsByID[id] {
1223
1263
  annotation.coordinate = value.coordinate
1224
1264
  annotation.title = value.title
1265
+ if let view = view(for: annotation) { configureMarkerView(view, id: id) }
1225
1266
  } else {
1226
1267
  let annotation = MKPointAnnotation()
1227
1268
  annotation.coordinate = value.coordinate
@@ -1266,17 +1307,24 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1266
1307
  // `region` is controlled: an authored change is allowed to move
1267
1308
  // the camera, even when the map itself was already mounted.
1268
1309
  pendingFitKey = nil
1269
- setRegion(region, animated: false)
1310
+ pendingRegion = bounds.width > 0 && bounds.height > 0 ? nil : region
1311
+ if abs(self.region.center.latitude - region.center.latitude) > 0.00000001
1312
+ || abs(self.region.center.longitude - region.center.longitude) > 0.00000001
1313
+ || abs(self.region.span.latitudeDelta - region.span.latitudeDelta) > 0.00000001
1314
+ || abs(self.region.span.longitudeDelta - region.span.longitudeDelta) > 0.00000001 {
1315
+ setRegion(region, animated: false)
1316
+ }
1270
1317
  initialCameraKey = nil
1271
1318
  } else if let initial = Self.region(props["initialRegion"]) {
1272
1319
  pendingFitKey = nil
1273
1320
  let key = "region:\(Self.regionKey(initial))"
1274
1321
  if initialCameraKey != key {
1322
+ pendingRegion = bounds.width > 0 && bounds.height > 0 ? nil : initial
1275
1323
  setRegion(initial, animated: false)
1276
1324
  initialCameraKey = key
1277
1325
  }
1278
1326
  } else if props["fitToBounds"] as? Bool == true {
1279
- let key = "fit:\(Self.overlayKey(markers: nextMarkers.mapValues { $0.coordinate }, polylines: nextPolylines.mapValues { $0.coordinates }))"
1327
+ let key = "fit:\(Self.overlayKey(markers: nextMarkers.mapValues { $0.coordinate }, polylines: nextPolylines.mapValues { $0.coordinates })):\(edgePadding)"
1280
1328
  if initialCameraKey != key {
1281
1329
  // A native screen can reconcile before UIKit assigns the map
1282
1330
  // its final safe-area-constrained bounds. Keep the fit
@@ -1292,10 +1340,96 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1292
1340
  initialCameraKey = nil
1293
1341
  pendingFitKey = nil
1294
1342
  }
1343
+ applyCameraOrientation()
1344
+ }
1345
+
1346
+ private func configureTiles(_ options: [String: Any]?) {
1347
+ let template = options?["urlTemplate"] as? String
1348
+ let credit = options?["attribution"] as? String ?? ""
1349
+ let valid = template.map { value in value.hasPrefix("https://") && ["{z}", "{x}", "{y}"].allSatisfy { value.contains($0) } } ?? false
1350
+ let next = valid && !credit.isEmpty ? template : nil
1351
+ if tileTemplate != next {
1352
+ if let overlay = tileOverlay { removeOverlay(overlay) }
1353
+ tileOverlay = nil
1354
+ tileTemplate = next
1355
+ if let next {
1356
+ let overlay = MKTileOverlay(urlTemplate: next)
1357
+ overlay.canReplaceMapContent = true
1358
+ tileOverlay = overlay
1359
+ insertOverlay(overlay, at: 0, level: .aboveRoads)
1360
+ }
1361
+ }
1362
+ tileAttribution.text = next == nil ? nil : credit
1363
+ tileAttribution.isHidden = next == nil
1364
+ }
1365
+
1366
+ private func applyCameraOrientation() {
1367
+ // Region-to-altitude conversion needs a measured viewport. Applying
1368
+ // pitch to the zero-altitude camera before layout clamps it to zero.
1369
+ guard bounds.width > 0, bounds.height > 0 else { return }
1370
+ let next = camera.copy() as! MKMapCamera
1371
+ var changed = false
1372
+ if let pitch = authoredPitch, pitch.isFinite {
1373
+ let value = CGFloat(min(60, max(0, pitch)))
1374
+ if abs(next.pitch - value) > 0.01 { next.pitch = value; changed = true }
1375
+ }
1376
+ if let heading = authoredHeading, heading.isFinite {
1377
+ let value = heading.truncatingRemainder(dividingBy: 360)
1378
+ if abs(next.heading - value) > 0.01 { next.heading = value; changed = true }
1379
+ }
1380
+ if changed { setCamera(next, animated: false) }
1381
+ }
1382
+
1383
+ @objc private func pressedMap(_ gesture: UITapGestureRecognizer) {
1384
+ guard gesture.state == .ended, onMapPress != nil else { return }
1385
+ let point = gesture.location(in: self)
1386
+ var touched = hitTest(point, with: nil)
1387
+ while let view = touched, view !== self {
1388
+ if view is MKAnnotationView { return }
1389
+ touched = view.superview
1390
+ }
1391
+ onMapPress?(convert(point, toCoordinateFrom: self))
1392
+ }
1393
+
1394
+ func mapView(_ mapView: MKMapView, regionWillChangeAnimated animated: Bool) {
1395
+ func interacting(_ view: UIView) -> Bool {
1396
+ if view.gestureRecognizers?.contains(where: { [.began, .changed, .ended].contains($0.state) }) == true { return true }
1397
+ return view.subviews.contains(where: interacting)
1398
+ }
1399
+ userChangingCamera = userChangingCamera || interacting(self)
1400
+ }
1401
+
1402
+ func mapView(_ mapView: MKMapView, regionDidChangeAnimated animated: Bool) {
1403
+ for (id, annotation) in annotationsByID {
1404
+ if let view = view(for: annotation) { configureMarkerView(view, id: id) }
1405
+ }
1406
+ if userChangingCamera {
1407
+ userChangingCamera = false
1408
+ onRegionChangeComplete?(region, camera.heading, Double(camera.pitch))
1409
+ }
1410
+ }
1411
+
1412
+ private func configureMarkerView(_ view: MKAnnotationView, id: String) {
1413
+ if let bearing = markerBearings[id] {
1414
+ let configuration = UIImage.SymbolConfiguration(pointSize: 24, weight: .semibold)
1415
+ .applying(UIImage.SymbolConfiguration(paletteColors: [.white, .systemGreen]))
1416
+ view.image = UIImage(systemName: "arrow.up.circle.fill", withConfiguration: configuration)
1417
+ view.transform = CGAffineTransform(rotationAngle: CGFloat((bearing - camera.heading) * .pi / 180))
1418
+ } else {
1419
+ view.image = UIImage(systemName: "mappin.circle.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 28))?
1420
+ .withTintColor(.systemRed, renderingMode: .alwaysOriginal)
1421
+ view.transform = .identity
1422
+ }
1295
1423
  }
1296
1424
 
1297
1425
  override func layoutSubviews() {
1298
1426
  super.layoutSubviews()
1427
+ if tileTemplate != nil { bringSubviewToFront(tileAttribution) }
1428
+ if let region = pendingRegion, bounds.width > 0, bounds.height > 0 {
1429
+ pendingRegion = nil
1430
+ setRegion(region, animated: false)
1431
+ applyCameraOrientation()
1432
+ }
1299
1433
  guard let key = pendingFitKey else { return }
1300
1434
  pendingFitKey = nil
1301
1435
  guard fitToOverlays() else {
@@ -1303,6 +1437,7 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1303
1437
  return
1304
1438
  }
1305
1439
  initialCameraKey = key
1440
+ applyCameraOrientation()
1306
1441
  }
1307
1442
 
1308
1443
  @discardableResult
@@ -1315,17 +1450,29 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1315
1450
  rect = rect.union(MKMapRect(x: point.x, y: point.y, width: 0, height: 0))
1316
1451
  }
1317
1452
  guard !rect.isNull else { return false }
1318
- let inset = UIEdgeInsets(top: 24, left: 24, bottom: 24, right: 24)
1453
+ var inset = edgePadding
1454
+ let horizontal = inset.left + inset.right
1455
+ let vertical = inset.top + inset.bottom
1456
+ let availableWidth = max(0, bounds.width - 40)
1457
+ let availableHeight = max(0, bounds.height - 40)
1458
+ if horizontal > availableWidth && horizontal > 0 {
1459
+ inset.left *= availableWidth / horizontal
1460
+ inset.right *= availableWidth / horizontal
1461
+ }
1462
+ if vertical > availableHeight && vertical > 0 {
1463
+ inset.top *= availableHeight / vertical
1464
+ inset.bottom *= availableHeight / vertical
1465
+ }
1319
1466
  setVisibleMapRect(rect, edgePadding: inset, animated: false)
1320
1467
  return true
1321
1468
  }
1322
1469
 
1323
1470
  func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
1324
1471
  guard let id = annotationsByID.first(where: { $0.value === annotation })?.key else { return nil }
1325
- let view = (dequeueReusableAnnotationView(withIdentifier: "craft-native-marker") as? MKMarkerAnnotationView)
1326
- ?? MKMarkerAnnotationView(annotation: annotation, reuseIdentifier: "craft-native-marker")
1472
+ let view = dequeueReusableAnnotationView(withIdentifier: "craft-native-marker")
1473
+ ?? MKAnnotationView(annotation: annotation, reuseIdentifier: "craft-native-marker")
1327
1474
  view.annotation = annotation
1328
- view.markerTintColor = .systemRed
1475
+ configureMarkerView(view, id: id)
1329
1476
  view.accessibilityIdentifier = id
1330
1477
  return view
1331
1478
  }
@@ -1337,6 +1484,7 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1337
1484
  }
1338
1485
 
1339
1486
  func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer {
1487
+ if let tiles = overlay as? MKTileOverlay { return MKTileOverlayRenderer(tileOverlay: tiles) }
1340
1488
  let renderer = MKPolylineRenderer(overlay: overlay)
1341
1489
  renderer.strokeColor = overlayColors[ObjectIdentifier(overlay)] ?? .systemBlue
1342
1490
  renderer.lineWidth = overlayWidths[ObjectIdentifier(overlay)] ?? 3
@@ -1834,7 +1982,172 @@ extension UIView {
1834
1982
  }
1835
1983
  }
1836
1984
 
1985
+ /// A finite, opt-in native celebration. Reconciliation with the same replay
1986
+ /// value never restarts it; Reduce Motion always leaves the complete card still.
1987
+ struct CraftNativeCelebrationSpec: Equatable {
1988
+ let replay: Double
1989
+ let enabled: Bool
1990
+
1991
+ init?(_ raw: Any?) {
1992
+ guard let value = raw as? [String: Any],
1993
+ let replay = value["replay"] as? NSNumber,
1994
+ CFGetTypeID(replay) != CFBooleanGetTypeID(),
1995
+ replay.doubleValue.isFinite, replay.doubleValue >= 0 else { return nil }
1996
+ self.replay = replay.doubleValue
1997
+ enabled = value["enabled"] as? Bool != false
1998
+ }
1999
+ }
2000
+
2001
+ final class CraftNativeCelebration {
2002
+ private weak var view: UIView?
2003
+ private var request: CraftNativeCelebrationSpec?
2004
+ private var pending = false
2005
+ private var overlay: CALayer?
2006
+ private var sheen: CALayer?
2007
+ private var cleanup: DispatchWorkItem?
2008
+ private var observers: [NSObjectProtocol] = []
2009
+ private(set) var starts = 0
2010
+ var isRunning: Bool { overlay != nil }
2011
+
2012
+ init(view: UIView) {
2013
+ self.view = view
2014
+ for name in [UIApplication.didEnterBackgroundNotification, UIAccessibility.reduceMotionStatusDidChangeNotification] {
2015
+ observers.append(NotificationCenter.default.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in self?.stop() })
2016
+ }
2017
+ }
2018
+
2019
+ deinit {
2020
+ cleanup?.cancel()
2021
+ overlay?.removeFromSuperlayer()
2022
+ sheen?.removeFromSuperlayer()
2023
+ observers.forEach(NotificationCenter.default.removeObserver)
2024
+ }
2025
+
2026
+ func configure(_ raw: Any?) {
2027
+ let next = CraftNativeCelebrationSpec(raw)
2028
+ guard next != request else { return }
2029
+ stop()
2030
+ request = next
2031
+ pending = next?.enabled == true
2032
+ view?.setNeedsLayout()
2033
+ }
2034
+
2035
+ func stop() {
2036
+ pending = false
2037
+ cleanup?.cancel()
2038
+ cleanup = nil
2039
+ view?.layer.removeAnimation(forKey: "craft.celebration.entrance")
2040
+ overlay?.removeFromSuperlayer()
2041
+ overlay = nil
2042
+ sheen?.removeFromSuperlayer()
2043
+ sheen = nil
2044
+ }
2045
+
2046
+ func layout(reduceMotion: Bool = UIAccessibility.isReduceMotionEnabled) {
2047
+ guard pending, let view, let window = view.window,
2048
+ view.bounds.width > 0, view.bounds.height > 0, !view.isHidden else { return }
2049
+ var ancestor: UIView? = view.superview
2050
+ while let parent = ancestor {
2051
+ if parent.isHidden { return }
2052
+ ancestor = parent.superview
2053
+ }
2054
+ pending = false
2055
+ guard !reduceMotion else { return }
2056
+ starts += 1
2057
+ let frame = view.convert(view.bounds, to: window)
2058
+ let overlay = CALayer()
2059
+ overlay.name = "craft.celebration.confetti"
2060
+ overlay.frame = window.bounds
2061
+ window.layer.addSublayer(overlay)
2062
+ self.overlay = overlay
2063
+
2064
+ // Core Animation owns every frame. No script timers, per-frame bridge
2065
+ // messages, bitmap assets or touch-intercepting overlay views.
2066
+ 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]
2067
+ let now = CACurrentMediaTime()
2068
+ for index in 0..<52 {
2069
+ let side: CGFloat = index % 2 == 0 ? -1 : 1
2070
+ let spread = CGFloat((index * 37) % 101) / 100
2071
+ let start = CGPoint(x: frame.midX + side * frame.width * 0.42, y: frame.minY + 28)
2072
+ let end = CGPoint(x: frame.midX + side * (35 + spread * window.bounds.width * 0.7), y: window.bounds.height + 30)
2073
+ let piece = CALayer()
2074
+ piece.bounds = CGRect(x: 0, y: 0, width: index % 3 == 0 ? 4 : 7, height: 11)
2075
+ piece.cornerRadius = 1
2076
+ piece.backgroundColor = colors[index % colors.count].cgColor
2077
+ piece.position = end
2078
+ piece.opacity = 0
2079
+ overlay.addSublayer(piece)
2080
+ let path = CAKeyframeAnimation(keyPath: "position")
2081
+ 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)]
2082
+ path.keyTimes = [0, 0.28, 1]
2083
+ path.calculationMode = .cubic
2084
+ let tumble = CABasicAnimation(keyPath: "transform.rotation")
2085
+ tumble.fromValue = side * Double(index)
2086
+ tumble.toValue = side * (Double(index) + 7 + Double(spread) * 10)
2087
+ let fade = CAKeyframeAnimation(keyPath: "opacity")
2088
+ fade.values = [0, 1, 1, 0]
2089
+ fade.keyTimes = [0, 0.04, 0.7, 1]
2090
+ let group = CAAnimationGroup()
2091
+ group.animations = [path, tumble, fade]
2092
+ group.duration = 2.2 + Double(spread) * 0.45
2093
+ group.beginTime = now + Double(index % 7) * 0.018
2094
+ group.fillMode = .backwards
2095
+ piece.add(group, forKey: "craft.celebration.particle")
2096
+ }
2097
+
2098
+ let entrance = CASpringAnimation(keyPath: "transform")
2099
+ var from = CATransform3DMakeTranslation(0, 34, 0)
2100
+ from = CATransform3DRotate(from, -0.045, 0, 0, 1)
2101
+ from = CATransform3DScale(from, 0.86, 0.86, 1)
2102
+ entrance.fromValue = NSValue(caTransform3D: CATransform3DConcat(view.layer.transform, from))
2103
+ entrance.toValue = NSValue(caTransform3D: view.layer.transform)
2104
+ entrance.damping = 19
2105
+ entrance.stiffness = 160
2106
+ entrance.mass = 1
2107
+ entrance.duration = 0.95
2108
+ view.layer.add(entrance, forKey: "craft.celebration.entrance")
2109
+
2110
+ let sheen = CAGradientLayer()
2111
+ sheen.name = "craft.celebration.sheen"
2112
+ sheen.frame = view.bounds
2113
+ sheen.colors = [UIColor.clear.cgColor, UIColor.white.withAlphaComponent(0.2).cgColor, UIColor.clear.cgColor]
2114
+ sheen.locations = [0.25, 0.5, 0.75]
2115
+ sheen.startPoint = CGPoint(x: 0, y: 0.3)
2116
+ sheen.endPoint = CGPoint(x: 1, y: 0.7)
2117
+ sheen.opacity = 0
2118
+ let mask = CAShapeLayer()
2119
+ mask.path = UIBezierPath(roundedRect: view.bounds, cornerRadius: view.layer.cornerRadius).cgPath
2120
+ sheen.mask = mask
2121
+ view.layer.addSublayer(sheen)
2122
+ self.sheen = sheen
2123
+ let sweep = CABasicAnimation(keyPath: "transform.translation.x")
2124
+ sweep.fromValue = -view.bounds.width
2125
+ sweep.toValue = view.bounds.width
2126
+ let light = CAKeyframeAnimation(keyPath: "opacity")
2127
+ light.values = [0, 1, 1, 0]
2128
+ light.keyTimes = [0, 0.15, 0.8, 1]
2129
+ let shine = CAAnimationGroup()
2130
+ shine.animations = [sweep, light]
2131
+ shine.duration = 1.15
2132
+ shine.beginTime = now + 0.55
2133
+ sheen.add(shine, forKey: "craft.celebration.sweep")
2134
+ let cleanup = DispatchWorkItem { [weak self] in self?.stop() }
2135
+ self.cleanup = cleanup
2136
+ DispatchQueue.main.asyncAfter(deadline: .now() + 3, execute: cleanup)
2137
+ }
2138
+ }
2139
+
1837
2140
  private class CraftNativeFlowView: UIStackView {
2141
+ var celebration: CraftNativeCelebration?
2142
+ func configureCelebration(_ raw: Any?) {
2143
+ if raw != nil && celebration == nil { celebration = CraftNativeCelebration(view: self) }
2144
+ celebration?.configure(raw)
2145
+ }
2146
+ override func didMoveToWindow() {
2147
+ super.didMoveToWindow()
2148
+ if window == nil { celebration?.stop() }
2149
+ else { setNeedsLayout() }
2150
+ }
1838
2151
  var pressActiveOpacity: CGFloat?
1839
2152
  var pressBaseOpacity: CGFloat = 1
1840
2153
  var pressUnderlayColor: UIColor?
@@ -2051,6 +2364,7 @@ private class CraftNativeFlowView: UIStackView {
2051
2364
  override func layoutSubviews() {
2052
2365
  super.layoutSubviews()
2053
2366
  craftApplyCornerRadius()
2367
+ celebration?.layout()
2054
2368
  let scale = window?.screen.scale ?? traitCollection.displayScale
2055
2369
  let result = layout(width: bounds.width, height: bounds.height, maxWidth: bounds.width, maxHeight: bounds.height, place: true)
2056
2370
  var moved = false
@@ -4134,6 +4448,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4134
4448
 
4135
4449
  override func viewWillDisappear(_ animated: Bool) {
4136
4450
  super.viewWillDisappear(animated)
4451
+ stopCelebrations(in: view)
4137
4452
  // The accessory belongs to the visible native screen. Clear the
4138
4453
  // shared shell state before a hybrid/web route appears, otherwise a
4139
4454
  // destination from the outgoing screen remains tappable on top of it.
@@ -4188,6 +4503,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4188
4503
  /// `SCREEN_FOCUS { focused: true }`, and going away is `focused: false`.
4189
4504
  func screenVisibilityChanged(_ visible: Bool) {
4190
4505
  guard !runtimeDisposed else { return }
4506
+ if !visible, isViewLoaded { stopCelebrations(in: view) }
4191
4507
  // Hidden tab stacks receive no UIKit appearance callback. A cancelled
4192
4508
  // preview also restores a screen that never lost focus, so restore its
4193
4509
  // chrome even when no new SCREEN_FOCUS event is needed.
@@ -6130,6 +6446,18 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6130
6446
  case "Map":
6131
6447
  let map = result as! CraftNativeMapView
6132
6448
  let mapMarkerHandler = nonEmptyHandler(events["onMarkerPress"])
6449
+ map.onMapPress = { [weak self] coordinate in
6450
+ guard let self, let handler = self.nonEmptyHandler(events["onPress"]) else { return }
6451
+ self.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["latitude": coordinate.latitude, "longitude": coordinate.longitude]])
6452
+ }
6453
+ map.onRegionChangeComplete = { [weak self] region, heading, pitch in
6454
+ guard let self, let handler = self.nonEmptyHandler(events["onRegionChangeComplete"]) else { return }
6455
+ self.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [
6456
+ "center": ["latitude": region.center.latitude, "longitude": region.center.longitude],
6457
+ "span": ["latitudeDelta": region.span.latitudeDelta, "longitudeDelta": region.span.longitudeDelta],
6458
+ "heading": heading, "pitch": pitch,
6459
+ ]])
6460
+ }
6133
6461
  map.configure(props: props, children: children) { [weak self, weak map] markerID in
6134
6462
  guard let self, let map,
6135
6463
  let handler = map.markerHandlers[markerID] ?? mapMarkerHandler else { return }
@@ -6217,9 +6545,15 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6217
6545
  if disabled { result.isUserInteractionEnabled = false }
6218
6546
  }
6219
6547
  applyAccessibility(props, type: type, to: result)
6548
+ (result as? CraftNativeFlowView)?.configureCelebration(props["celebration"])
6220
6549
  return current
6221
6550
  }
6222
6551
 
6552
+ private func stopCelebrations(in root: UIView) {
6553
+ (root as? CraftNativeFlowView)?.celebration?.stop()
6554
+ root.subviews.forEach { stopCelebrations(in: $0) }
6555
+ }
6556
+
6223
6557
  private func makeView(_ type: String, props: [String: Any]) -> UIView {
6224
6558
  switch type {
6225
6559
  case "Text":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "craft-native",
3
- "version": "0.0.235",
3
+ "version": "0.0.237",
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>",