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.
- package/dist/android/templates/CraftNativeCapabilities.kt.template +19 -1
- package/dist/android/templates/MainActivityNative.kt.template +303 -5
- package/dist/cli.js +1 -1
- package/dist/components/index.d.ts +33 -0
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/src/app-shortcuts.d.ts +11 -0
- package/dist/ios/src/index.d.ts +2 -0
- package/dist/ios/src/index.js +56 -0
- package/dist/ios/templates/CraftApp.swift +16 -0
- package/dist/ios/templates/CraftNativeScreen.swift +340 -6
- package/package.json +1 -1
|
@@ -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" ->
|
|
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)
|
|
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)
|
|
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.
|
|
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
package/dist/index.js
CHANGED
|
@@ -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;
|
package/dist/ios/src/index.d.ts
CHANGED
|
@@ -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;
|
package/dist/ios/src/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 =
|
|
1326
|
-
??
|
|
1472
|
+
let view = dequeueReusableAnnotationView(withIdentifier: "craft-native-marker")
|
|
1473
|
+
?? MKAnnotationView(annotation: annotation, reuseIdentifier: "craft-native-marker")
|
|
1327
1474
|
view.annotation = annotation
|
|
1328
|
-
view
|
|
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":
|