craft-native 0.0.246 → 0.0.248

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.
Files changed (46) hide show
  1. package/dist/android/src/index.d.ts +11 -0
  2. package/dist/android/src/index.js +33 -21
  3. package/dist/android/templates/AndroidManifest.xml.template +1 -0
  4. package/dist/android/templates/CraftBridge.kt.template +136 -1
  5. package/dist/android/templates/CraftHeartRate.kt.template +938 -0
  6. package/dist/android/templates/CraftHeartRateCore.kt.template +485 -0
  7. package/dist/android/templates/CraftNative.kt.template +17 -0
  8. package/dist/android/templates/CraftNativeFlatList.kt.template +115 -40
  9. package/dist/android/templates/CraftNativeMapGoogle.kt.template +92 -5
  10. package/dist/android/templates/CraftNativeMapLibre.kt.template +90 -3
  11. package/dist/android/templates/CraftNativeMutation.kt.template +32 -0
  12. package/dist/android/templates/CraftNativeRefreshableFlatList.kt.template +7 -2
  13. package/dist/android/templates/CraftNativeServices.kt.template +59 -1
  14. package/dist/android/templates/CraftNativeShell.kt.template +54 -14
  15. package/dist/android/templates/MainActivityNative.kt.template +369 -69
  16. package/dist/android/templates/build.gradle.kts.project.template +1 -1
  17. package/dist/api/mobile.d.ts +39 -3
  18. package/dist/browser.js +9 -9
  19. package/dist/cli.js +90 -673
  20. package/dist/components/index.d.ts +50 -9
  21. package/dist/index.cjs +394 -438
  22. package/dist/index.d.cts +1 -1
  23. package/dist/index.d.ts +1 -1
  24. package/dist/index.js +446 -493
  25. package/dist/ios/src/index.d.ts +2 -0
  26. package/dist/ios/src/index.js +54 -53
  27. package/dist/ios/templates/CraftApp.swift +37 -2
  28. package/dist/ios/templates/CraftHealth.swift +217 -0
  29. package/dist/ios/templates/CraftHeartRate.swift +6 -3
  30. package/dist/ios/templates/CraftHybrid.swift +18 -19
  31. package/dist/ios/templates/CraftLiveActivities.swift +1 -1
  32. package/dist/ios/templates/CraftNativeActions.swift +87 -8
  33. package/dist/ios/templates/CraftNativeFiles.swift +15 -3
  34. package/dist/ios/templates/CraftNativeFlatList.swift +18 -1
  35. package/dist/ios/templates/CraftNativeMutation.swift +36 -0
  36. package/dist/ios/templates/CraftNativeScreen.swift +813 -65
  37. package/dist/ios/templates/CraftNativeServices.swift +4 -2
  38. package/dist/ios/templates/CraftNativeUI.swift +10 -2
  39. package/dist/ios/templates/testing/DriverTests.swift.template +1 -0
  40. package/dist/ios/templates/testing/HostApp.swift +9 -2
  41. package/dist/macos/templates/CraftNativeMutation.swift +36 -0
  42. package/dist/macos/templates/native_layout.zig +226 -2
  43. package/dist/mobile-testing.js +21 -3
  44. package/dist/mobile.js +101 -63
  45. package/dist/testing/media-cache/media-cache.kt +4 -2
  46. package/package.json +1 -1
@@ -12,6 +12,7 @@ import androidx.recyclerview.widget.PagerSnapHelper
12
12
  import androidx.recyclerview.widget.RecyclerView
13
13
  import org.json.JSONObject
14
14
  import kotlin.math.ceil
15
+ import kotlin.math.max
15
16
 
16
17
  internal typealias CraftNativeRenderItem = (node: JSONObject, identity: String, previous: View?) -> View
17
18
 
@@ -96,6 +97,7 @@ internal class CraftNativeFlatList(context: Context) : RecyclerView(context) {
96
97
 
97
98
  private var renderItem: CraftNativeRenderItem? = null
98
99
  private var recycleItem: ((String) -> Unit)? = null
100
+ private var removeItem: ((String) -> Unit)? = null
99
101
  private var endReached: (() -> Unit)? = null
100
102
  private var threshold = 0.1
101
103
  private var endReachedSignature: String? = null
@@ -120,6 +122,49 @@ internal class CraftNativeFlatList(context: Context) : RecyclerView(context) {
120
122
  var onMomentumScrollBegin: (() -> Unit)? = null
121
123
  var onMomentumScrollEnd: (() -> Unit)? = null
122
124
  var onLayoutChanged: (() -> Unit)? = null
125
+ var onContentSizeChanged: (() -> Unit)? = null
126
+ private var lastIntrinsicHeight = Int.MIN_VALUE
127
+ private var lastReportedContentHeight = Int.MIN_VALUE
128
+
129
+ private val scrollListener = object : OnScrollListener() {
130
+ private var dragging = false
131
+ private var momentum = false
132
+
133
+ override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
134
+ onScrollEvent?.invoke()
135
+ onLayoutChanged?.invoke()
136
+ evaluateEndReached()
137
+ }
138
+
139
+ override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
140
+ when (newState) {
141
+ RecyclerView.SCROLL_STATE_DRAGGING -> if (!dragging) {
142
+ dragging = true
143
+ onScrollBeginDrag?.invoke()
144
+ }
145
+ RecyclerView.SCROLL_STATE_SETTLING -> {
146
+ if (dragging) {
147
+ dragging = false
148
+ onScrollEndDrag?.invoke()
149
+ }
150
+ if (!momentum) {
151
+ momentum = true
152
+ onMomentumScrollBegin?.invoke()
153
+ }
154
+ }
155
+ RecyclerView.SCROLL_STATE_IDLE -> {
156
+ if (dragging) {
157
+ dragging = false
158
+ onScrollEndDrag?.invoke()
159
+ }
160
+ if (momentum) {
161
+ momentum = false
162
+ onMomentumScrollEnd?.invoke()
163
+ }
164
+ }
165
+ }
166
+ }
167
+ }
123
168
 
124
169
  private fun holderLayoutParams() = LayoutParams(
125
170
  if (horizontal) LayoutParams.WRAP_CONTENT else LayoutParams.MATCH_PARENT,
@@ -132,7 +177,11 @@ internal class CraftNativeFlatList(context: Context) : RecyclerView(context) {
132
177
  }
133
178
 
134
179
  fun setScrollEnabled(enabled: Boolean) {
180
+ if (scrollEnabled == enabled) return
135
181
  scrollEnabled = enabled
182
+ lastIntrinsicHeight = Int.MIN_VALUE
183
+ lastReportedContentHeight = Int.MIN_VALUE
184
+ requestLayout()
136
185
  }
137
186
 
138
187
  val isScrollEnabledForTest: Boolean
@@ -151,6 +200,26 @@ internal class CraftNativeFlatList(context: Context) : RecyclerView(context) {
151
200
  val isScrollTargetPendingForTest: Boolean
152
201
  get() = pendingScrollTarget != null
153
202
 
203
+ /** Stable identities currently mounted, in their adapter order. */
204
+ val visibleItemIdentities: List<String>
205
+ get() = (0 until childCount)
206
+ .mapNotNull { index ->
207
+ val child = getChildAt(index)
208
+ val position = getChildAdapterPosition(child)
209
+ val order = if (position == RecyclerView.NO_POSITION) {
210
+ // ListAdapter can briefly report NO_POSITION while an
211
+ // async diff is dispatching. Keep the mounted holder in
212
+ // the snapshot instead of making the entire list appear
213
+ // empty to host-side reconciliation tests.
214
+ Int.MAX_VALUE - childCount + index
215
+ } else {
216
+ position
217
+ }
218
+ (getChildViewHolder(child) as? Holder)?.identity?.let { order to it }
219
+ }
220
+ .sortedBy { it.first }
221
+ .map { it.second }
222
+
154
223
  override fun onInterceptTouchEvent(event: android.view.MotionEvent): Boolean {
155
224
  if (!scrollEnabled) return false
156
225
  return super.onInterceptTouchEvent(event)
@@ -161,6 +230,29 @@ internal class CraftNativeFlatList(context: Context) : RecyclerView(context) {
161
230
  return super.onTouchEvent(event)
162
231
  }
163
232
 
233
+ override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
234
+ val heightMode = MeasureSpec.getMode(heightMeasureSpec)
235
+ if (!scrollEnabled && !horizontal && heightMode != MeasureSpec.EXACTLY) {
236
+ // A non-scrolling FlatList is content-sized on iOS. Let the
237
+ // RecyclerView measure its full adapter range instead of keeping
238
+ // the viewport height, which otherwise clips the list when it is
239
+ // nested in an outer native ScrollView.
240
+ super.onMeasure(widthMeasureSpec, MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED))
241
+ val contentHeight = max(measuredHeight, computeVerticalScrollRange())
242
+ val resolvedHeight = resolveSize(contentHeight, heightMeasureSpec)
243
+ val contentChanged = resolvedHeight != lastIntrinsicHeight
244
+ lastIntrinsicHeight = resolvedHeight
245
+ setMeasuredDimension(
246
+ resolveSize(measuredWidth, widthMeasureSpec),
247
+ resolvedHeight,
248
+ )
249
+ if (contentChanged) reportContentSizeChanged()
250
+ return
251
+ }
252
+ lastIntrinsicHeight = Int.MIN_VALUE
253
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec)
254
+ }
255
+
164
256
  fun setBounceOptions(enabled: Boolean, vertical: Boolean, horizontal: Boolean) {
165
257
  bounces = enabled
166
258
  alwaysBounceVertical = vertical
@@ -182,51 +274,21 @@ internal class CraftNativeFlatList(context: Context) : RecyclerView(context) {
182
274
  override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
183
275
  super.onLayout(changed, left, top, right, bottom)
184
276
  scrollToPendingTarget(0)
277
+ if (!scrollEnabled && !horizontal) reportContentSizeChanged()
185
278
  onLayoutChanged?.invoke()
186
279
  }
187
280
 
281
+ private fun reportContentSizeChanged() {
282
+ val height = measuredHeight
283
+ if (height <= 0 || height == lastReportedContentHeight) return
284
+ lastReportedContentHeight = height
285
+ post { onContentSizeChanged?.invoke() }
286
+ }
287
+
188
288
  init {
189
289
  adapter = listAdapter
190
290
  itemAnimator = null
191
- addOnScrollListener(object : OnScrollListener() {
192
- private var dragging = false
193
- private var momentum = false
194
-
195
- override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
196
- onScrollEvent?.invoke()
197
- onLayoutChanged?.invoke()
198
- evaluateEndReached()
199
- }
200
-
201
- override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
202
- when (newState) {
203
- RecyclerView.SCROLL_STATE_DRAGGING -> if (!dragging) {
204
- dragging = true
205
- onScrollBeginDrag?.invoke()
206
- }
207
- RecyclerView.SCROLL_STATE_SETTLING -> {
208
- if (dragging) {
209
- dragging = false
210
- onScrollEndDrag?.invoke()
211
- }
212
- if (!momentum) {
213
- momentum = true
214
- onMomentumScrollBegin?.invoke()
215
- }
216
- }
217
- RecyclerView.SCROLL_STATE_IDLE -> {
218
- if (dragging) {
219
- dragging = false
220
- onScrollEndDrag?.invoke()
221
- }
222
- if (momentum) {
223
- momentum = false
224
- onMomentumScrollEnd?.invoke()
225
- }
226
- }
227
- }
228
- }
229
- })
291
+ addOnScrollListener(scrollListener)
230
292
  }
231
293
 
232
294
  fun setKeyboardDismissMode(mode: String, dismissKeyboard: () -> Unit) {
@@ -291,10 +353,12 @@ internal class CraftNativeFlatList(context: Context) : RecyclerView(context) {
291
353
  endReachedThreshold: Double,
292
354
  renderItem: CraftNativeRenderItem,
293
355
  recycleItem: (String) -> Unit,
356
+ removeItem: (String) -> Unit,
294
357
  endReached: (() -> Unit)?,
295
358
  ) {
296
359
  this.renderItem = renderItem
297
360
  this.recycleItem = recycleItem
361
+ this.removeItem = removeItem
298
362
  this.endReached = endReached
299
363
  threshold = endReachedThreshold.takeIf { it.isFinite() }?.coerceAtLeast(0.0) ?: 0.1
300
364
 
@@ -321,7 +385,7 @@ internal class CraftNativeFlatList(context: Context) : RecyclerView(context) {
321
385
 
322
386
  val oldIds = listAdapter.currentList.mapTo(mutableSetOf()) { it.identity }
323
387
  val nextIds = next.mapTo(mutableSetOf()) { it.identity }
324
- (oldIds - nextIds).forEach(recycleItem)
388
+ (oldIds - nextIds).forEach(removeItem)
325
389
  val dataItems = next.filterNot { it.chrome }
326
390
  val contentSignature = dataItems.joinToString("") {
327
391
  "${it.identity.length}:${it.identity}${it.signature.length}:${it.signature}"
@@ -386,6 +450,7 @@ internal class CraftNativeFlatList(context: Context) : RecyclerView(context) {
386
450
  }
387
451
  renderItem = null
388
452
  recycleItem = null
453
+ removeItem = null
389
454
  endReached = null
390
455
  setPagingEnabled(false)
391
456
  scrollTargetKey = null
@@ -397,6 +462,16 @@ internal class CraftNativeFlatList(context: Context) : RecyclerView(context) {
397
462
  onMomentumScrollBegin = null
398
463
  onMomentumScrollEnd = null
399
464
  onLayoutChanged = null
465
+ onContentSizeChanged = null
466
+ lastIntrinsicHeight = Int.MIN_VALUE
467
+ lastReportedContentHeight = Int.MIN_VALUE
468
+ }
469
+
470
+ /** Releases callbacks owned by a permanently removed native list node. */
471
+ fun resetForTeardown() {
472
+ discardAll()
473
+ removeOnScrollListener(scrollListener)
474
+ setOnTouchListener(null)
400
475
  }
401
476
 
402
477
  fun refreshThemeDefaults() {
@@ -1,5 +1,6 @@
1
1
  /** The Android host for the small, host-neutral native trail map contract. */
2
2
  private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMapReadyCallback {
3
+ private data class PolygonValue(val points: List<LatLng>, val fillColor: Int, val strokeColor: Int, val strokeWidth: Float)
3
4
  private val mapView = MapView(context)
4
5
  private val scaleView = LinearLayout(context).apply {
5
6
  orientation = LinearLayout.VERTICAL
@@ -17,6 +18,7 @@ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMap
17
18
  private var pendingChildren = JSONArray()
18
19
  private val markers = mutableMapOf<String, Marker>()
19
20
  private val polylines = mutableMapOf<String, Polyline>()
21
+ private val polygons = mutableMapOf<String, Polygon>()
20
22
  private val markerHandlers = mutableMapOf<String, String>()
21
23
  private var onMarkerTap: ((String) -> Unit)? = null
22
24
  var onMapPress: ((LatLng) -> Unit)? = null
@@ -40,10 +42,23 @@ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMap
40
42
  })
41
43
  scaleView.visibility = View.GONE
42
44
  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() })
45
+ refreshThemeDefaults()
43
46
  mapView.onCreate(null)
44
47
  mapView.getMapAsync(this)
45
48
  }
46
49
 
50
+ fun refreshThemeDefaults() {
51
+ val dark = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES
52
+ val foreground = if (dark) Color.rgb(245, 245, 245) else Color.rgb(37, 51, 33)
53
+ val surface = if (dark) Color.argb(230, 38, 38, 38) else Color.argb(230, 255, 255, 255)
54
+ scaleView.background = GradientDrawable().apply { setColor(surface); cornerRadius = 4f }
55
+ scaleLabel.setTextColor(foreground)
56
+ scaleLine.setBackgroundColor(foreground)
57
+ tileAttribution.setTextColor(foreground)
58
+ tileAttribution.setBackgroundColor(surface)
59
+ applyPending(refreshCamera = false)
60
+ }
61
+
47
62
  override fun onMapReady(map: GoogleMap) {
48
63
  if (destroyed) return
49
64
  googleMap = map
@@ -85,7 +100,7 @@ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMap
85
100
 
86
101
  fun markerHandler(id: String): String? = markerHandlers[id]
87
102
 
88
- private fun applyPending() {
103
+ private fun applyPending(refreshCamera: Boolean = true) {
89
104
  val map = googleMap ?: return
90
105
  applyMapPadding()
91
106
  map.uiSettings.isCompassEnabled = craftNativeBoolean(pendingProps.opt("showsCompass"))
@@ -109,6 +124,31 @@ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMap
109
124
  val nextPinColors = mutableMapOf<String, Int>()
110
125
  val nextHandlers = mutableMapOf<String, String>()
111
126
  val nextPolylines = mutableMapOf<String, Triple<List<LatLng>, Int, Float>>()
127
+ val nextPolygons = mutableMapOf<String, PolygonValue>()
128
+ val dataMarkers = pendingProps.optJSONArray("markers")
129
+ for (index in 0 until (dataMarkers?.length() ?: 0)) {
130
+ val overlay = dataMarkers?.optJSONObject(index) ?: continue
131
+ val id = craftNativeOptionalString(overlay.opt("id")) ?: continue
132
+ val point = coordinates(overlay.optJSONArray("geometry")).firstOrNull() ?: continue
133
+ nextMarkers[id] = point to (overlay.opt("title") as? String)
134
+ (overlay.opt("pinColor") as? String)?.let { nextPinColors[id] = color(it) }
135
+ (overlay.opt("bearing") as? Number)?.toDouble()?.takeIf { it.isFinite() }?.let { nextBearings[id] = (it % 360).toFloat() }
136
+ }
137
+ val dataPolylines = pendingProps.optJSONArray("polylines")
138
+ for (index in 0 until (dataPolylines?.length() ?: 0)) {
139
+ val overlay = dataPolylines?.optJSONObject(index) ?: continue
140
+ val id = craftNativeOptionalString(overlay.opt("id")) ?: continue
141
+ val points = coordinates(overlay.optJSONArray("geometry"))
142
+ if (points.size <= 1) continue
143
+ val authoredWidth = when (val rawWidth = overlay.opt("strokeWidth")) {
144
+ null, JSONObject.NULL -> 3.0
145
+ is Number -> rawWidth.toDouble()
146
+ else -> 3.0
147
+ }
148
+ val width = (authoredWidth * resources.displayMetrics.density).toFloat()
149
+ if (!authoredWidth.isFinite() || !width.isFinite() || width <= 0f) continue
150
+ nextPolylines[id] = Triple(points, color(craftNativeOptionalString(overlay.opt("strokeColor")).orEmpty()), width)
151
+ }
112
152
  for (index in 0 until pendingChildren.length()) {
113
153
  val child = pendingChildren.optJSONObject(index) ?: continue
114
154
  val type = child.optString("type").lowercase()
@@ -121,7 +161,7 @@ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMap
121
161
  if (type == "mapmarker" || type == "marker") {
122
162
  points.firstOrNull()?.let { point ->
123
163
  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 } }
164
+ (props.opt("pinColor") as? String)?.let { value -> nextPinColors[id] = color(value) }
125
165
  (props.opt("bearing") as? Number)?.toDouble()?.takeIf { it.isFinite() }?.let { nextBearings[id] = (it % 360).toFloat() }
126
166
  listOf(events.opt("onPress"), events.opt("onMarkerPress"))
127
167
  .firstNotNullOfOrNull(::craftNativeOptionalString)
@@ -147,6 +187,18 @@ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMap
147
187
  if (!authoredWidth.isFinite() || !width.isFinite() || width <= 0f) continue
148
188
  nextPolylines[id] = Triple(points, color(craftNativeOptionalString(props.opt("strokeColor")).orEmpty()), width)
149
189
  }
190
+ } else if (type == "mappolygon" || type == "polygon") {
191
+ if (points.size > 2) {
192
+ val rawWidth = props.opt("strokeWidth")
193
+ val authoredWidth = when (rawWidth) {
194
+ null, JSONObject.NULL -> 2.0
195
+ is Number -> rawWidth.toDouble()
196
+ else -> 2.0
197
+ }
198
+ val width = (authoredWidth * resources.displayMetrics.density).toFloat()
199
+ if (!authoredWidth.isFinite() || !width.isFinite() || width <= 0f) continue
200
+ nextPolygons[id] = PolygonValue(points, color(craftNativeOptionalString(props.opt("fillColor")).orEmpty(), Color.argb(72, 33, 150, 83)), color(craftNativeOptionalString(props.opt("strokeColor")).orEmpty()), width)
201
+ }
150
202
  }
151
203
  }
152
204
  markerBearings = nextBearings
@@ -181,6 +233,22 @@ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMap
181
233
  map.addPolyline(PolylineOptions().addAll(value.first).color(value.second).width(value.third))?.let { polylines[id] = it }
182
234
  }
183
235
  }
236
+ polygons.keys.filter { nextPolygons[it] == null }.forEach { id -> polygons.remove(id)?.remove() }
237
+ for ((id, value) in nextPolygons) {
238
+ val polygon = polygons[id]
239
+ if (polygon != null) {
240
+ polygon.points = value.points
241
+ polygon.fillColor = value.fillColor
242
+ polygon.strokeColor = value.strokeColor
243
+ polygon.strokeWidth = value.strokeWidth
244
+ } else {
245
+ map.addPolygon(PolygonOptions().addAll(value.points).fillColor(value.fillColor).strokeColor(value.strokeColor).strokeWidth(value.strokeWidth))?.let { polygons[id] = it }
246
+ }
247
+ }
248
+ if (!refreshCamera) {
249
+ updateScale()
250
+ return
251
+ }
184
252
  val explicitRegion = region(pendingProps.optJSONObject("region"))
185
253
  val initialRegion = region(pendingProps.optJSONObject("initialRegion"))
186
254
  if (explicitRegion != null) {
@@ -196,7 +264,7 @@ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMap
196
264
  initialCameraKey = key
197
265
  }
198
266
  } else if (craftNativeBoolean(pendingProps.opt("fitToBounds"))) {
199
- val key = "fit:${overlayKey(nextMarkers, nextPolylines)}:${pendingProps.optJSONObject("edgePadding")}"
267
+ val key = "fit:${overlayKey(nextMarkers, nextPolylines, nextPolygons)}:${pendingProps.optJSONObject("edgePadding")}"
200
268
  if (initialCameraKey != key) {
201
269
  if (fitToBounds()) initialCameraKey = key
202
270
  }
@@ -323,6 +391,7 @@ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMap
323
391
  private fun overlayKey(
324
392
  markers: Map<String, Pair<LatLng, String?>>,
325
393
  polylines: Map<String, Triple<List<LatLng>, Int, Float>>,
394
+ polygons: Map<String, PolygonValue>,
326
395
  ): String {
327
396
  val markerKey = markers.toSortedMap().entries.joinToString("|") { (id, value) ->
328
397
  "$id:${value.first.latitude},${value.first.longitude}"
@@ -330,7 +399,10 @@ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMap
330
399
  val polylineKey = polylines.toSortedMap().entries.joinToString("|") { (id, value) ->
331
400
  "$id:" + value.first.joinToString(";") { point -> "${point.latitude},${point.longitude}" }
332
401
  }
333
- return "m[$markerKey]p[$polylineKey]"
402
+ val polygonKey = polygons.toSortedMap().entries.joinToString("|") { (id, value) ->
403
+ "$id:" + value.points.joinToString(";") { point -> "${point.latitude},${point.longitude}" }
404
+ }
405
+ return "m[$markerKey]p[$polylineKey]g[$polygonKey]"
334
406
  }
335
407
 
336
408
  private fun fitToBounds(): Boolean {
@@ -340,6 +412,8 @@ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMap
340
412
  var count = 0
341
413
  markers.values.forEach { builder.include(it.position); count++ }
342
414
  polylines.values.flatMap { it.points }.forEach { builder.include(it); count++ }
415
+ polygons.values.flatMap { it.points }.forEach { builder.include(it); count++ }
416
+ polygons.values.flatMap { it.points }.forEach { builder.include(it); count++ }
343
417
  if (count == 0) return false
344
418
  post {
345
419
  if (width > 0 && height > 0) {
@@ -388,6 +462,8 @@ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMap
388
462
  markers.clear()
389
463
  polylines.values.forEach { it.remove() }
390
464
  polylines.clear()
465
+ polygons.values.forEach { it.remove() }
466
+ polygons.clear()
391
467
  mapView.onDestroy()
392
468
  googleMap = null
393
469
  }
@@ -435,6 +511,17 @@ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMap
435
511
  private fun finiteNumber(value: Any?): Double? =
436
512
  (value as? Number)?.toDouble()?.takeIf { it.isFinite() }
437
513
 
438
- private fun color(value: String): Int = runCatching { craftNativeColor(value) }.getOrDefault(Color.BLUE)
514
+ private fun color(value: String): Int {
515
+ return color(value, Color.BLUE)
516
+ }
517
+
518
+ private fun color(value: String, fallback: Int): Int {
519
+ val dark = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES
520
+ return when (value.trim().lowercase()) {
521
+ "label" -> if (dark) Color.WHITE else Color.BLACK
522
+ "secondarylabel", "secondary-label" -> if (dark) Color.LTGRAY else Color.DKGRAY
523
+ else -> runCatching { craftNativeColor(value) }.getOrDefault(fallback)
524
+ }
525
+ }
439
526
  }
440
527
 
@@ -57,11 +57,24 @@ internal class CraftNativeMapView(context: Context) : FrameLayout(context), OnMa
57
57
  rightMargin = (8 * resources.displayMetrics.density).roundToInt()
58
58
  bottomMargin = (8 * resources.displayMetrics.density).roundToInt()
59
59
  })
60
+ refreshThemeDefaults()
60
61
  mapView.onCreate(null)
61
62
  mapView.addOnDidFinishLoadingMapListener { if (!destroyed && initialCameraKey == null) applyPending() }
62
63
  mapView.getMapAsync(this)
63
64
  }
64
65
 
66
+ fun refreshThemeDefaults() {
67
+ val dark = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES
68
+ val foreground = if (dark) Color.rgb(245, 245, 245) else Color.rgb(37, 51, 33)
69
+ val surface = if (dark) Color.argb(230, 38, 38, 38) else Color.argb(230, 255, 255, 255)
70
+ attribution.setTextColor(foreground)
71
+ attribution.setBackgroundColor(surface)
72
+ scale.setBackgroundColor(surface)
73
+ scaleLabel.setTextColor(foreground)
74
+ scaleLine.setBackgroundColor(foreground)
75
+ readyStyle?.let { updateOverlays(it) }
76
+ }
77
+
65
78
  override fun onMapReady(nativeMap: MapLibreMap) {
66
79
  if (destroyed) return
67
80
  map = nativeMap
@@ -109,6 +122,14 @@ internal class CraftNativeMapView(context: Context) : FrameLayout(context), OnMa
109
122
  fun markerHandler(id: String): String? = markerHandlers[id]
110
123
 
111
124
  private fun applyPending() {
125
+ // MapLibre may deliver style and render callbacks from its worker
126
+ // thread. All map, style, and GeoJSON source mutations must happen on
127
+ // Android's UI thread, so marshal the whole reconciliation before
128
+ // touching the native map object.
129
+ if (Looper.myLooper() != Looper.getMainLooper()) {
130
+ post { applyPending() }
131
+ return
132
+ }
112
133
  val nativeMap = map ?: return
113
134
  if (destroyed) return
114
135
  nativeMap.uiSettings.isCompassEnabled = craftNativeBoolean(pendingProps.opt("showsCompass"))
@@ -200,6 +221,12 @@ internal class CraftNativeMapView(context: Context) : FrameLayout(context), OnMa
200
221
  style.addLayer(LineLayer("craft-map-routes", "craft-map-overlays")
201
222
  .withFilter(Expression.eq(Expression.get("kind"), "line"))
202
223
  .withProperties(lineColor(Expression.toColor(Expression.get("color"))), lineWidth(Expression.get("width")), lineCap("round"), lineJoin("round")))
224
+ style.addLayer(FillLayer("craft-map-polygons", "craft-map-overlays")
225
+ .withFilter(Expression.eq(Expression.get("kind"), "polygon"))
226
+ .withProperties(fillColor(Expression.toColor(Expression.get("fillColor")))))
227
+ style.addLayer(LineLayer("craft-map-polygon-strokes", "craft-map-overlays")
228
+ .withFilter(Expression.eq(Expression.get("kind"), "polygon"))
229
+ .withProperties(lineColor(Expression.toColor(Expression.get("strokeColor"))), lineWidth(Expression.get("strokeWidth")), lineCap("round"), lineJoin("round")))
203
230
  style.addLayer(CircleLayer("craft-map-markers", "craft-map-overlays")
204
231
  .withFilter(Expression.eq(Expression.get("kind"), "marker"))
205
232
  .withProperties(circleColor(Expression.toColor(Expression.get("color"))), circleRadius(7f), circleStrokeColor(Color.WHITE), circleStrokeWidth(2f)))
@@ -212,6 +239,42 @@ internal class CraftNativeMapView(context: Context) : FrameLayout(context), OnMa
212
239
  val features = JSONArray()
213
240
  val pointsForFit = mutableListOf<LatLng>()
214
241
  markerHandlers.clear()
242
+ val dataMarkers = pendingProps.optJSONArray("markers")
243
+ for (index in 0 until (dataMarkers?.length() ?: 0)) {
244
+ val overlay = dataMarkers?.optJSONObject(index) ?: continue
245
+ val id = craftNativeOptionalString(overlay.opt("id")) ?: continue
246
+ val point = coordinates(overlay.optJSONArray("geometry")).firstOrNull() ?: continue
247
+ val bearing = finiteNumber(overlay.opt("bearing"))
248
+ val properties = JSONObject()
249
+ .put("craftOverlayId", id)
250
+ .put("kind", if (bearing == null) "marker" else "direction")
251
+ .put("craftMarkerId", id)
252
+ .put("bearing", (bearing ?: 0.0) % 360)
253
+ .put("color", cssColor(overlay.opt("pinColor"), Color.rgb(36, 114, 75)))
254
+ val geometry = JSONObject().put("type", "Point").put("coordinates", JSONArray().put(point.longitude).put(point.latitude))
255
+ features.put(JSONObject().put("type", "Feature").put("properties", properties).put("geometry", geometry))
256
+ pointsForFit.add(point)
257
+ }
258
+ val dataPolylines = pendingProps.optJSONArray("polylines")
259
+ for (index in 0 until (dataPolylines?.length() ?: 0)) {
260
+ val overlay = dataPolylines?.optJSONObject(index) ?: continue
261
+ val id = craftNativeOptionalString(overlay.opt("id")) ?: continue
262
+ val points = coordinates(overlay.optJSONArray("geometry"))
263
+ if (points.size <= 1) continue
264
+ val rawWidth = overlay.opt("strokeWidth")
265
+ val logicalWidth = if (rawWidth == null || rawWidth == JSONObject.NULL) 3.0 else finiteNumber(rawWidth) ?: continue
266
+ val width = (logicalWidth * resources.displayMetrics.density).takeIf { it.isFinite() && it > 0 } ?: continue
267
+ val properties = JSONObject()
268
+ .put("craftOverlayId", id)
269
+ .put("kind", "line")
270
+ .put("color", cssColor(overlay.opt("strokeColor"), Color.BLUE))
271
+ .put("width", width)
272
+ val geometry = JSONObject().put("type", "LineString").put("coordinates", JSONArray().apply {
273
+ points.forEach { put(JSONArray().put(it.longitude).put(it.latitude)) }
274
+ })
275
+ features.put(JSONObject().put("type", "Feature").put("properties", properties).put("geometry", geometry))
276
+ pointsForFit.addAll(points)
277
+ }
215
278
  for (index in 0 until pendingChildren.length()) {
216
279
  val child = pendingChildren.optJSONObject(index) ?: continue
217
280
  val type = child.optString("type").lowercase()
@@ -231,11 +294,25 @@ internal class CraftNativeMapView(context: Context) : FrameLayout(context), OnMa
231
294
  pointsForFit.add(point)
232
295
  } else if ((type == "mappolyline" || type == "polyline") && points.size > 1) {
233
296
  val authoredWidth = props.opt("strokeWidth")
234
- val width = if (authoredWidth == null || authoredWidth == JSONObject.NULL) 3.0 else finiteNumber(authoredWidth) ?: continue
235
- if (width <= 0 || !width.toFloat().isFinite()) continue
297
+ val logicalWidth = if (authoredWidth == null || authoredWidth == JSONObject.NULL) 3.0 else finiteNumber(authoredWidth) ?: continue
298
+ // The shared contract is expressed in logical points. MapLibre's
299
+ // style line-width is in physical pixels, just like the Google
300
+ // Maps polyline width, so convert only at this host boundary.
301
+ val width = (logicalWidth * resources.displayMetrics.density)
302
+ .takeIf { it.isFinite() && it > 0 } ?: continue
236
303
  properties.put("kind", "line").put("color", cssColor(props.opt("strokeColor"), Color.BLUE)).put("width", width)
237
304
  geometry.put("type", "LineString").put("coordinates", JSONArray().apply { points.forEach { put(JSONArray().put(it.longitude).put(it.latitude)) } })
238
305
  pointsForFit.addAll(points)
306
+ } else if ((type == "mappolygon" || type == "polygon") && points.size > 2) {
307
+ val rawWidth = props.opt("strokeWidth")
308
+ val logicalWidth = if (rawWidth == null || rawWidth == JSONObject.NULL) 2.0 else finiteNumber(rawWidth) ?: continue
309
+ val width = (logicalWidth * resources.displayMetrics.density).takeIf { it.isFinite() && it > 0 } ?: continue
310
+ val fill = cssColor(props.opt("fillColor"), Color.argb(72, 33, 150, 83))
311
+ val stroke = cssColor(props.opt("strokeColor"), Color.rgb(33, 150, 83))
312
+ properties.put("kind", "polygon").put("fillColor", fill).put("strokeColor", stroke).put("strokeWidth", width)
313
+ val ring = points + points.first()
314
+ geometry.put("type", "Polygon").put("coordinates", JSONArray().put(JSONArray().apply { ring.forEach { put(JSONArray().put(it.longitude).put(it.latitude)) } }))
315
+ pointsForFit.addAll(points)
239
316
  } else continue
240
317
  features.put(JSONObject().put("type", "Feature").put("properties", properties).put("geometry", geometry))
241
318
  }
@@ -244,10 +321,20 @@ internal class CraftNativeMapView(context: Context) : FrameLayout(context), OnMa
244
321
  }
245
322
 
246
323
  private fun cssColor(value: Any?, fallback: Int): String {
247
- val color = (value as? String)?.let { runCatching { craftNativeColor(it) }.getOrNull() } ?: fallback
324
+ val color = (value as? String)?.let { mapColor(it, fallback) } ?: fallback
248
325
  return "rgba(${Color.red(color)},${Color.green(color)},${Color.blue(color)},${Color.alpha(color) / 255.0})"
249
326
  }
250
327
 
328
+ private fun mapColor(raw: String, fallback: Int): Int {
329
+ val value = raw.trim().lowercase()
330
+ val dark = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES
331
+ return when (value) {
332
+ "label" -> if (dark) Color.WHITE else Color.BLACK
333
+ "secondarylabel", "secondary-label" -> if (dark) Color.LTGRAY else Color.DKGRAY
334
+ else -> runCatching { craftNativeColor(raw) }.getOrDefault(fallback)
335
+ }
336
+ }
337
+
251
338
  private fun directionBitmap(): Bitmap {
252
339
  val size = (26 * resources.displayMetrics.density).roundToInt().coerceAtLeast(24)
253
340
  val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
@@ -111,6 +111,14 @@ class CraftNativeMutationDocument {
111
111
  moveChild(operation, candidateNodes)
112
112
  operation.optString("parentId").takeIf { it !in affectedNodeIds }?.let { affectedNodeIds.add(it) }
113
113
  }
114
+ "reparentChild" -> {
115
+ val previousParent = reparentChild(operation, candidateNodes)
116
+ operation.optString("parentId").takeIf { it !in affectedNodeIds }?.let { affectedNodeIds.add(it) }
117
+ previousParent.takeIf { it !in affectedNodeIds }?.let { affectedNodeIds.add(it) }
118
+ // A child crossing parents needs the committed tree to
119
+ // be reconciled so the host can move its existing view.
120
+ requiresFullRender = true
121
+ }
114
122
  "removeNode" -> {
115
123
  val parent = candidateNodes[operation.optString("id")]?.parent
116
124
  if (parent == null) requiresFullRender = true
@@ -152,6 +160,9 @@ class CraftNativeMutationDocument {
152
160
 
153
161
  fun node(id: String): JSONObject? = materialize(id, nodes)
154
162
 
163
+ /** Reserve surviving protocol nodes before reusing an authored keyed slot. */
164
+ fun containsNode(id: String): Boolean = nodes.containsKey(id)
165
+
155
166
  /** The committed parent used to redraw a child that has no native view. */
156
167
  fun parentId(id: String): String? = nodes[id]?.parent
157
168
 
@@ -225,6 +236,27 @@ class CraftNativeMutationDocument {
225
236
  parent.children.add(insertionIndex(operation, parent.children.size), childId)
226
237
  }
227
238
 
239
+ private fun reparentChild(operation: JSONObject, nodes: MutableMap<String, Node>): String {
240
+ val parentId = requiredId(operation, "parentId", "reparentChild")
241
+ val childId = requiredId(operation, "childId", "reparentChild")
242
+ val parent = nodes[parentId] ?: fail("UNKNOWN_PARENT", "node $parentId does not exist")
243
+ val child = nodes[childId] ?: fail("UNKNOWN_NODE", "node $childId does not exist")
244
+ val previousParentId = child.parent
245
+ ?: fail("NOT_ATTACHED", "node $childId is not attached to a parent")
246
+ val previousParent = nodes[previousParentId]
247
+ ?: fail("INVALID_TREE", "node $childId has a missing parent")
248
+ if (!acceptsChildren(parent.type)) fail("INVALID_PARENT", "node $parentId cannot contain child nodes")
249
+ if (parentId == childId || parentId in descendants(childId, nodes)) {
250
+ fail("CYCLE", "reparenting node $childId would create a cycle")
251
+ }
252
+ if (parentId == previousParentId) fail("SAME_PARENT", "node $childId is already a child of $parentId")
253
+ val index = insertionIndex(operation, parent.children.size)
254
+ previousParent.children.remove(childId)
255
+ parent.children.add(index, childId)
256
+ child.parent = parentId
257
+ return previousParentId
258
+ }
259
+
228
260
  private fun removeNode(
229
261
  operation: JSONObject,
230
262
  nodes: MutableMap<String, Node>,
@@ -22,6 +22,12 @@ internal class CraftNativeRefreshableFlatList(context: Context) : SwipeRefreshLa
22
22
  if (handler == null) isRefreshing = false
23
23
  }
24
24
 
25
+ fun resetForTeardown() {
26
+ refreshHandler = null
27
+ isRefreshing = false
28
+ isEnabled = false
29
+ }
30
+
25
31
  fun setRefreshAppearance(props: JSONObject, fallback: Int) {
26
32
  val tint = props.optString("tintColor").takeIf { it.isNotBlank() }
27
33
  val resolved = tint?.let { runCatching { craftNativeColor(it) }.getOrNull() } ?: fallback
@@ -29,8 +35,7 @@ internal class CraftNativeRefreshableFlatList(context: Context) : SwipeRefreshLa
29
35
  }
30
36
 
31
37
  override fun onDetachedFromWindow() {
32
- refreshHandler = null
33
- isRefreshing = false
38
+ resetForTeardown()
34
39
  super.onDetachedFromWindow()
35
40
  }
36
41
  }