craft-native 0.0.236 → 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()
@@ -5746,6 +5894,16 @@ class MainActivity : AppCompatActivity() {
5746
5894
  }
5747
5895
  "Map" -> (previous as? CraftNativeMapView ?: CraftNativeMapView(this)).apply {
5748
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
+ }
5749
5907
  configure(props, children) { markerId ->
5750
5908
  (markerHandler(markerId) ?: mapMarkerHandler)?.let { handler ->
5751
5909
  send(screen, "EVENT", JSONObject().put("handlerName", handler).put("nativeEvent", JSONObject().put("id", markerId)))
@@ -7153,7 +7311,7 @@ class MainActivity : AppCompatActivity() {
7153
7311
 
7154
7312
  override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
7155
7313
  super.onRequestPermissionsResult(requestCode, permissions, grantResults)
7156
- if (::capabilities.isInitialized) capabilities.onBluetoothPermissions(requestCode)
7314
+ if (::capabilities.isInitialized) { capabilities.onBluetoothPermissions(requestCode); capabilities.onNotificationPermissions(requestCode) }
7157
7315
  if (::services.isInitialized) services.onRequestPermissionsResult(requestCode)
7158
7316
  }
7159
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.236";
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.236";
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.236";
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
@@ -6298,6 +6446,18 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6298
6446
  case "Map":
6299
6447
  let map = result as! CraftNativeMapView
6300
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
+ }
6301
6461
  map.configure(props: props, children: children) { [weak self, weak map] markerID in
6302
6462
  guard let self, let map,
6303
6463
  let handler = map.markerHandlers[markerID] ?? mapMarkerHandler else { return }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "craft-native",
3
- "version": "0.0.236",
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>",