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/fixtures/native-navigation/NativeMapTest.ktmodified+10-1
Changes to packages/android/fixtures/native-navigation/NativeMapTest.kt
@@ -154,6 +154,8 @@ class NativeMapTest {
154154 assertEquals(11.0, start.longitude(), 0.000001)
155155 assertEquals(90.0, features(map).first { it.getStringProperty("craftOverlayId") == "direction" }.getNumberProperty("bearing").toDouble(), 0.001)
156156 assertTrue("Provider attribution must remain visible", textViews(host).any { it.visibility == View.VISIBLE && (it.text.contains("Esri") || it.text.contains("OpenStreetMap")) })
157 val credit = textViews(host).first { it.text.contains("Esri") || it.text.contains("OpenStreetMap") }
158 assertTrue("Floating profiles must not cover provider attribution", credit.bottom <= host.height - 180 * host.resources.displayMetrics.density)
157159 for (point in listOf(LatLng(47.0, 11.0), LatLng(47.01, 11.01))) {
158160 val screen = map.projection.toScreenLocation(point)
159161 val density = host.resources.displayMetrics.density
@@ -168,7 +170,8 @@ class NativeMapTest {
168170 @Test
169171 fun nativeMapMutationsUpdateThePinAndCameraWithoutReloadingTheStyle() = withMap { _, host, _, map, _ ->
170172 val style = main { map.style }
171 main { host.configure(props().put("pitchEnabled", true).put("cameraPitch", 30).put("cameraHeading", 90), overlays("#2878c8")) {} }
173 val pressed = AtomicReference<String?>()
174 main { host.configure(props().put("pitchEnabled", true).put("cameraPitch", 30).put("cameraHeading", 90), overlays("#2878c8")) { pressed.set(it) } }
172175 awaitState("The native map did not apply camera and pin mutations") {
173176 val pin = features(map).firstOrNull { it.getStringProperty("craftOverlayId") == "start" }
174177 pin?.getStringProperty("color") == "rgba(40,120,200,1.0)" &&
@@ -195,6 +198,12 @@ class NativeMapTest {
195198 assertEquals(120.0, Color.green(pixel).toDouble(), 8.0)
196199 assertEquals(200.0, Color.blue(pixel).toDouble(), 8.0)
197200 } finally { image.recycle() }
201 val downAt = SystemClock.uptimeMillis()
202 for (action in listOf(android.view.MotionEvent.ACTION_DOWN, android.view.MotionEvent.ACTION_UP)) {
203 val touch = android.view.MotionEvent.obtain(downAt, SystemClock.uptimeMillis(), action, location.first.toFloat(), location.second.toFloat(), 0)
204 try { assertTrue(instrumentation.uiAutomation.injectInputEvent(touch, true)) } finally { touch.recycle() }
205 }
206 awaitState("Tapping the actual native pin must dispatch its stable marker ID") { pressed.get() == "start" }
198207 evidence("map-mutated-pin-and-camera")
199208 }
200209
packages/android/templates/CraftNativeMapLibre.kt.templatemodified+10-0
Changes to packages/android/templates/CraftNativeMapLibre.kt.template
@@ -271,6 +271,16 @@ internal class CraftNativeMapView(context: Context) : FrameLayout(context), OnMa
271271 if (horizontal > availableWidth && horizontal > 0) { left *= availableWidth / horizontal; right *= availableWidth / horizontal }
272272 if (vertical > availableHeight && vertical > 0) { top *= availableHeight / vertical; bottom *= availableHeight / vertical }
273273 map?.setPadding(left.roundToInt(), top.roundToInt(), right.roundToInt(), bottom.roundToInt())
274 // Floating STX controls reserve the same space for credits and scale
275 // as for route fitting, so a profile never covers either utility.
276 (attribution.layoutParams as LayoutParams).let { params ->
277 val margin = bottom.roundToInt() + (8 * resources.displayMetrics.density).roundToInt()
278 if (params.bottomMargin != margin) { params.bottomMargin = margin; attribution.layoutParams = params }
279 }
280 (scale.layoutParams as LayoutParams).let { params ->
281 val margin = bottom.roundToInt() + (32 * resources.displayMetrics.density).roundToInt()
282 if (params.bottomMargin != margin) { params.bottomMargin = margin; scale.layoutParams = params }
283 }
274284 }
275285
276286 private fun moveCamera(bounds: LatLngBounds, padding: Int) {
packages/ios/fixtures/native-screen/NativeRenderUnitTests.swiftmodified+5-2
Changes to packages/ios/fixtures/native-screen/NativeRenderUnitTests.swift
@@ -843,7 +843,7 @@ final class NativeRenderUnitTests: XCTestCase {
843843 let controller = CraftNativeScreenController(config: CraftConfig())
844844 controller.loadViewIfNeeded()
845845 controller.render([
846 "type": "Map", "props": ["key": "topo-map", "tileOverlay": ["urlTemplate": "https://example.org/tiles/{z}/{y}/{x}.png", "attribution": "Test map data"]],
846 "type": "Map", "props": ["key": "topo-map", "edgePadding": ["bottom": 160], "tileOverlay": ["urlTemplate": "https://example.org/tiles/{z}/{y}/{x}.png", "attribution": "Test map data"]],
847847 "style": ["width": 320, "height": 480],
848848 "children": [["type": "MapPolyline", "props": ["geometry": [37.7, -119.5, 37.72, -119.49]]]],
849849 ])
@@ -852,7 +852,10 @@ final class NativeRenderUnitTests: XCTestCase {
852852 XCTAssertTrue(tiles.canReplaceMapContent)
853853 XCTAssertTrue(map.delegate?.mapView?(map, rendererFor: tiles) is MKTileOverlayRenderer)
854854 XCTAssertTrue(map.overlays.last is MKPolyline, "The route must remain above the basemap")
855 XCTAssertTrue(map.subviews.compactMap { $0 as? UILabel }.contains { $0.text == "Test map data" && !$0.isHidden })
855 controller.view.layoutIfNeeded()
856 map.layoutIfNeeded()
857 let credit = try XCTUnwrap(map.subviews.compactMap { $0 as? UILabel }.first { $0.text == "Test map data" && !$0.isHidden })
858 XCTAssertLessThanOrEqual(credit.frame.maxY, map.bounds.height - 160, "The floating profile must not cover map attribution")
856859 }
857860
858861 func testMapReservesRoomForFloatingControls() throws {
packages/ios/templates/CraftNativeScreen.swiftmodified+8-2
Changes to packages/ios/templates/CraftNativeScreen.swift
@@ -1174,6 +1174,7 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
11741174 private var markerPinColors: [String: UIColor] = [:]
11751175 private var tileOverlay: MKTileOverlay?
11761176 private var tileTemplate: String?
1177 private var tileAttributionBottom: NSLayoutConstraint?
11771178 private lazy var tileAttribution: UILabel = {
11781179 let label = UILabel()
11791180 label.font = .systemFont(ofSize: 9)
@@ -1182,7 +1183,9 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
11821183 label.translatesAutoresizingMaskIntoConstraints = false
11831184 label.numberOfLines = 2
11841185 addSubview(label)
1185 NSLayoutConstraint.activate([label.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8), label.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -8), label.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: 48)])
1186 let bottom = label.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -8)
1187 tileAttributionBottom = bottom
1188 NSLayoutConstraint.activate([label.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8), bottom, label.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: 48)])
11861189 return label
11871190 }()
11881191
@@ -1433,7 +1436,10 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
14331436
14341437 override func layoutSubviews() {
14351438 super.layoutSubviews()
1436 if tileTemplate != nil { bringSubviewToFront(tileAttribution) }
1439 if tileTemplate != nil {
1440 tileAttributionBottom?.constant = -min(edgePadding.bottom, max(0, bounds.height - 40)) - 8
1441 bringSubviewToFront(tileAttribution)
1442 }
14371443 if let region = pendingRegion, bounds.width > 0, bounds.height > 0 {
14381444 pendingRegion = nil
14391445 setRegion(region, animated: false)