ReviewOS

also looking at this

craft-native/craft

feat(maps): add key-free native Android maps and shared map controls

#363
Merged chris wants to merge codex/wildloop-native-map-providers into main
28 files +1857 -481

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

packages/android/templates/CraftNativeMapGoogle.kt.templatemodified+25-3
Changes to packages/android/templates/CraftNativeMapGoogle.kt.template
@@ -23,6 +23,8 @@ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMap
2323 var onRegionChangeComplete: ((JSONObject) -> Unit)? = null
2424 private var userChangingCamera = false
2525 private var markerBearings = mutableMapOf<String, Float>()
26 private var markerPinColors = mutableMapOf<String, Int>()
27 private val pinIcons = mutableMapOf<Int, com.google.android.gms.maps.model.BitmapDescriptor>()
2628 private var directionIcon: com.google.android.gms.maps.model.BitmapDescriptor? = null
2729 private var lifecycleStarted = false
2830 private var destroyed = false
@@ -104,6 +106,7 @@ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMap
104106 runCatching { map.isMyLocationEnabled = showUserLocation }
105107 val nextMarkers = mutableMapOf<String, Pair<LatLng, String?>>()
106108 val nextBearings = mutableMapOf<String, Float>()
109 val nextPinColors = mutableMapOf<String, Int>()
107110 val nextHandlers = mutableMapOf<String, String>()
108111 val nextPolylines = mutableMapOf<String, Triple<List<LatLng>, Int, Float>>()
109112 for (index in 0 until pendingChildren.length()) {
@@ -118,6 +121,7 @@ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMap
118121 if (type == "mapmarker" || type == "marker") {
119122 points.firstOrNull()?.let { point ->
120123 nextMarkers[id] = point to (props.opt("title") as? String)
124 (props.opt("pinColor") as? String)?.let { value -> runCatching { craftNativeColor(value) }.getOrNull()?.let { nextPinColors[id] = it } }
121125 (props.opt("bearing") as? Number)?.toDouble()?.takeIf { it.isFinite() }?.let { nextBearings[id] = (it % 360).toFloat() }
122126 listOf(events.opt("onPress"), events.opt("onMarkerPress"))
123127 .firstNotNullOfOrNull(::craftNativeOptionalString)
@@ -146,18 +150,20 @@ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMap
146150 }
147151 }
148152 markerBearings = nextBearings
153 markerPinColors = nextPinColors
154 pinIcons.keys.filter { it !in markerPinColors.values }.forEach { pinIcons.remove(it) }
149155 markers.keys.filter { nextMarkers[it] == null }.forEach { markers.remove(it)?.remove() }
150156 for ((id, value) in nextMarkers) {
151157 val marker = markers[id]
152158 if (marker != null) {
153159 marker.position = value.first
154160 marker.title = value.second
155 marker.setIcon(if (markerBearings.containsKey(id)) directionMarkerIcon() else BitmapDescriptorFactory.defaultMarker())
161 marker.setIcon(if (markerBearings.containsKey(id)) directionMarkerIcon() else pinMarkerIcon(id))
156162 marker.isFlat = markerBearings.containsKey(id)
157163 marker.rotation = markerBearings[id] ?: 0f
158 marker.setAnchor(0.5f, if (markerBearings.containsKey(id)) 0.5f else 1f)
164 marker.setAnchor(0.5f, if (markerBearings.containsKey(id) || markerPinColors.containsKey(id)) 0.5f else 1f)
159165 } else {
160 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 }
166 map.addMarker(MarkerOptions().position(value.first).title(value.second).icon(if (markerBearings.containsKey(id)) directionMarkerIcon() else pinMarkerIcon(id)).flat(markerBearings.containsKey(id)).rotation(markerBearings[id] ?: 0f).anchor(0.5f, if (markerBearings.containsKey(id) || markerPinColors.containsKey(id)) 0.5f else 1f))?.let { markers[id] = it }
161167 }
162168 }
163169 markerHandlers.clear()
@@ -261,6 +267,21 @@ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMap
261267 }
262268 }
263269
270 private fun pinMarkerIcon(id: String): com.google.android.gms.maps.model.BitmapDescriptor {
271 val color = markerPinColors[id] ?: return BitmapDescriptorFactory.defaultMarker()
272 return pinIcons.getOrPut(color) {
273 val size = (18 * resources.displayMetrics.density).roundToInt().coerceAtLeast(18)
274 val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
275 val canvas = Canvas(bitmap)
276 val paint = Paint(Paint.ANTI_ALIAS_FLAG)
277 paint.color = Color.WHITE
278 canvas.drawCircle(size / 2f, size / 2f, size / 2f, paint)
279 paint.color = color
280 canvas.drawCircle(size / 2f, size / 2f, size * 0.38f, paint)
281 BitmapDescriptorFactory.fromBitmap(bitmap)
282 }
283 }
284
264285 private fun directionMarkerIcon(): com.google.android.gms.maps.model.BitmapDescriptor {
265286 directionIcon?.let { return it }
266287 val size = (26 * resources.displayMetrics.density).roundToInt().coerceAtLeast(24)
@@ -360,6 +381,7 @@ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMap
360381 onMapPress = null
361382 onRegionChangeComplete = null
362383 directionIcon = null
384 pinIcons.clear(); markerPinColors.clear()
363385 tileOverlay?.remove(); tileOverlay = null
364386 markerHandlers.clear()
365387 markers.values.forEach { it.remove() }
packages/ios/fixtures/native-screen/NativeRenderUnitTests.swiftmodified+28-0
Changes to packages/ios/fixtures/native-screen/NativeRenderUnitTests.swift
@@ -811,6 +811,34 @@ final class NativeRenderUnitTests: XCTestCase {
811811 XCTAssertEqual(marker.transform.b, 1, accuracy: 0.02)
812812 }
813813
814 func testMapUpdatesNativePinColorAndTiltGestureWithoutReplacingTheMarker() throws {
815 let controller = CraftNativeScreenController(config: CraftConfig())
816 let window = try hostWindow()
817 window.rootViewController = controller
818 window.makeKeyAndVisible()
819 defer { window.isHidden = true }
820 controller.loadViewIfNeeded()
821 func document(_ color: String, _ pitch: Bool) -> [String: Any] {
822 ["type": "Map", "props": ["key": "colored-map", "pitchEnabled": pitch],
823 "style": ["width": 320, "height": 320],
824 "children": [["type": "MapMarker", "props": ["id": "start", "geometry": [37.7, -119.5], "pinColor": color]]]]
825 }
826 controller.render(document("#24724b", false))
827 controller.view.layoutIfNeeded()
828 let map = try XCTUnwrap(find(MKMapView.self, key: "colored-map", below: controller.view))
829 let annotation = try XCTUnwrap(map.annotations.first)
830 let marker = try XCTUnwrap(map.delegate?.mapView?(map, viewFor: annotation))
831 XCTAssertFalse(map.isPitchEnabled)
832 XCTAssertEqual(marker.tintColor, UIColor(red: 36 / 255, green: 114 / 255, blue: 75 / 255, alpha: 1))
833 XCTAssertEqual(marker.image?.renderingMode, .alwaysOriginal)
834 controller.render(document("#2878c8", true))
835 controller.view.layoutIfNeeded()
836 XCTAssertTrue(map.isPitchEnabled)
837 XCTAssertTrue(map.annotations.first === annotation)
838 let updated = try XCTUnwrap(map.delegate?.mapView?(map, viewFor: annotation))
839 XCTAssertEqual(updated.tintColor, UIColor(red: 40 / 255, green: 120 / 255, blue: 200 / 255, alpha: 1))
840 }
841
814842 func testMapUsesNativeTopographicTilesWithAttribution() throws {
815843 let controller = CraftNativeScreenController(config: CraftConfig())
816844 controller.loadViewIfNeeded()
packages/ios/templates/CraftNativeScreen.swiftmodified+6-1
Changes to packages/ios/templates/CraftNativeScreen.swift
@@ -1171,6 +1171,7 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
11711171 private var authoredHeading: Double?
11721172 private var edgePadding = UIEdgeInsets(top: 24, left: 24, bottom: 24, right: 24)
11731173 private var markerBearings: [String: Double] = [:]
1174 private var markerPinColors: [String: UIColor] = [:]
11741175 private var tileOverlay: MKTileOverlay?
11751176 private var tileTemplate: String?
11761177 private lazy var tileAttribution: UILabel = {
@@ -1220,6 +1221,7 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
12201221 }
12211222 edgePadding = UIEdgeInsets(top: inset("top"), left: inset("left"), bottom: inset("bottom"), right: inset("right"))
12221223 var nextBearings: [String: Double] = [:]
1224 var nextPinColors: [String: UIColor] = [:]
12231225 // Showing the user dot must not trigger a permission prompt from a
12241226 // renderer pass. The app's existing location capability owns that
12251227 // prompt; a map only opts in after authorization is already granted.
@@ -1240,6 +1242,7 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
12401242 if type == "mapmarker" || type == "marker" {
12411243 guard let point = points.first else { continue }
12421244 nextMarkers[id] = (point, childProps["title"] as? String)
1245 if let color = childProps["pinColor"] as? String { nextPinColors[id] = Self.color(color) }
12431246 if let bearing = Self.numeric(childProps["bearing"]), bearing.isFinite {
12441247 nextBearings[id] = bearing.truncatingRemainder(dividingBy: 360)
12451248 }
@@ -1257,6 +1260,7 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
12571260 }
12581261
12591262 markerBearings = nextBearings
1263 markerPinColors = nextPinColors
12601264 let staleMarkers = annotationsByID.keys.filter { nextMarkers[$0] == nil }
12611265 removeAnnotations(staleMarkers.compactMap { annotationsByID.removeValue(forKey: $0) })
12621266 for (id, value) in nextMarkers {
@@ -1420,8 +1424,9 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
14201424 .withRenderingMode(.alwaysOriginal)
14211425 view.transform = CGAffineTransform(rotationAngle: CGFloat((bearing - camera.heading) * .pi / 180))
14221426 } else {
1427 view.tintColor = markerPinColors[id] ?? .systemRed
14231428 view.image = UIImage(systemName: "mappin.circle.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 28))?
1424 .withTintColor(.systemRed, renderingMode: .alwaysOriginal)
1429 .withTintColor(view.tintColor, renderingMode: .alwaysOriginal)
14251430 view.transform = .identity
14261431 }
14271432 }