craft-native 0.0.173 → 0.0.174

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.
@@ -86,6 +86,17 @@ import androidx.javascriptengine.Message
86
86
  import androidx.javascriptengine.MessagePort
87
87
  import androidx.javascriptengine.MessagePortClient
88
88
  import com.craft.runtime.CraftNative
89
+ import com.google.android.gms.maps.CameraUpdateFactory
90
+ import com.google.android.gms.maps.GoogleMap
91
+ import com.google.android.gms.maps.MapView
92
+ import com.google.android.gms.maps.OnMapReadyCallback
93
+ import com.google.android.gms.maps.model.BitmapDescriptorFactory
94
+ import com.google.android.gms.maps.model.LatLng
95
+ import com.google.android.gms.maps.model.LatLngBounds
96
+ import com.google.android.gms.maps.model.Marker
97
+ import com.google.android.gms.maps.model.MarkerOptions
98
+ import com.google.android.gms.maps.model.Polyline
99
+ import com.google.android.gms.maps.model.PolylineOptions
89
100
  import org.json.JSONArray
90
101
  import org.json.JSONObject
91
102
  import java.net.URL
@@ -459,6 +470,145 @@ private class CraftNativeSvgView(context: Context) : View(context) {
459
470
  private data class Quadruple<T1, T2, T3, T4>(val first: T1, val second: T2, val third: T3, val fourth: T4)
460
471
  }
461
472
 
473
+ /** The Android host for the small, host-neutral native trail map contract. */
474
+ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMapReadyCallback {
475
+ private val mapView = MapView(context)
476
+ private var googleMap: GoogleMap? = null
477
+ private var pendingProps = JSONObject()
478
+ private var pendingChildren = JSONArray()
479
+ private val markers = mutableMapOf<String, Marker>()
480
+ private val polylines = mutableMapOf<String, Polyline>()
481
+ private val markerHandlers = mutableMapOf<String, String>()
482
+ private var onMarkerTap: ((String) -> Unit)? = null
483
+
484
+ init {
485
+ addView(mapView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
486
+ mapView.onCreate(null)
487
+ mapView.getMapAsync(this)
488
+ }
489
+
490
+ override fun onMapReady(map: GoogleMap) {
491
+ googleMap = map
492
+ map.setOnMarkerClickListener { marker ->
493
+ val id = markers.entries.firstOrNull { it.value == marker }?.key ?: return@setOnMarkerClickListener false
494
+ onMarkerTap?.invoke(id)
495
+ false
496
+ }
497
+ applyPending()
498
+ }
499
+
500
+ fun configure(props: JSONObject, children: JSONArray, markerHandler: (String) -> Unit) {
501
+ pendingProps = props
502
+ pendingChildren = children
503
+ onMarkerTap = markerHandler
504
+ applyPending()
505
+ }
506
+
507
+ fun markerHandler(id: String): String? = markerHandlers[id]
508
+
509
+ private fun applyPending() {
510
+ val map = googleMap ?: return
511
+ map.uiSettings.isScrollGesturesEnabled = pendingProps.optBoolean("scrollEnabled", true)
512
+ map.uiSettings.isZoomGesturesEnabled = pendingProps.optBoolean("zoomEnabled", true)
513
+ map.uiSettings.isRotateGesturesEnabled = pendingProps.optBoolean("rotateEnabled", false)
514
+ map.mapType = when (pendingProps.optString("mapType").lowercase()) {
515
+ "satellite" -> GoogleMap.MAP_TYPE_SATELLITE
516
+ "hybrid" -> GoogleMap.MAP_TYPE_HYBRID
517
+ "terrain" -> GoogleMap.MAP_TYPE_TERRAIN
518
+ else -> GoogleMap.MAP_TYPE_NORMAL
519
+ }
520
+ val nextMarkers = mutableMapOf<String, Pair<LatLng, String?>>()
521
+ val nextHandlers = mutableMapOf<String, String>()
522
+ val nextPolylines = mutableMapOf<String, Pair<List<LatLng>, Int>>()
523
+ for (index in 0 until pendingChildren.length()) {
524
+ val child = pendingChildren.optJSONObject(index) ?: continue
525
+ val type = child.optString("type").lowercase()
526
+ val props = child.optJSONObject("props") ?: JSONObject()
527
+ val events = child.optJSONObject("events") ?: JSONObject()
528
+ val id = props.opt("id")?.takeUnless { it == JSONObject.NULL }?.toString()
529
+ ?: child.optString("id").takeIf { it.isNotBlank() }
530
+ ?: "overlay-$index"
531
+ val points = coordinates(props.optJSONArray("geometry"))
532
+ if (type == "mapmarker" || type == "marker") {
533
+ points.firstOrNull()?.let { point ->
534
+ nextMarkers[id] = point to props.optString("title").takeIf { it.isNotBlank() }
535
+ events.optString("onPress").ifBlank { events.optString("onMarkerPress") }
536
+ .takeIf { it.isNotBlank() }?.let { nextHandlers[id] = it }
537
+ }
538
+ } else if (type == "mappolyline" || type == "polyline") {
539
+ if (points.size > 1) nextPolylines[id] = points to color(props.optString("strokeColor"))
540
+ }
541
+ }
542
+ markers.keys.filter { nextMarkers[it] == null }.forEach { markers.remove(it)?.remove() }
543
+ for ((id, value) in nextMarkers) {
544
+ val marker = markers[id]
545
+ if (marker != null) {
546
+ marker.position = value.first
547
+ marker.title = value.second
548
+ } else {
549
+ map.addMarker(MarkerOptions().position(value.first).title(value.second).icon(BitmapDescriptorFactory.defaultMarker()))?.let { markers[id] = it }
550
+ }
551
+ }
552
+ markerHandlers.clear()
553
+ markerHandlers.putAll(nextHandlers)
554
+ polylines.values.forEach { it.remove() }
555
+ polylines.clear()
556
+ for ((id, value) in nextPolylines) {
557
+ polylines[id] = map.addPolyline(PolylineOptions().addAll(value.first).color(value.second).width(3f))
558
+ }
559
+ if (pendingProps.optBoolean("fitToBounds", false)) fitToBounds()
560
+ region(pendingProps.optJSONObject("region") ?: pendingProps.optJSONObject("initialRegion"))?.let { bounds ->
561
+ post {
562
+ if (width > 0 && height > 0) map.moveCamera(CameraUpdateFactory.newLatLngBounds(bounds, 24))
563
+ }
564
+ }
565
+ }
566
+
567
+ private fun fitToBounds() {
568
+ val map = googleMap ?: return
569
+ val builder = LatLngBounds.Builder()
570
+ var count = 0
571
+ markers.values.forEach { builder.include(it.position); count++ }
572
+ polylines.values.flatMap { it.points }.forEach { builder.include(it); count++ }
573
+ if (count == 0) return
574
+ post {
575
+ if (width > 0 && height > 0) map.moveCamera(CameraUpdateFactory.newLatLngBounds(builder.build(), 24))
576
+ }
577
+ }
578
+
579
+ override fun onAttachedToWindow() {
580
+ super.onAttachedToWindow()
581
+ mapView.onResume()
582
+ }
583
+
584
+ override fun onDetachedFromWindow() {
585
+ mapView.onPause()
586
+ super.onDetachedFromWindow()
587
+ }
588
+
589
+ private fun coordinates(geometry: JSONArray?): List<LatLng> {
590
+ if (geometry == null) return emptyList()
591
+ return (0 until geometry.length() - 1 step 2).mapNotNull { index ->
592
+ val latitude = geometry.optDouble(index, Double.NaN)
593
+ val longitude = geometry.optDouble(index + 1, Double.NaN)
594
+ if (latitude.isFinite() && longitude.isFinite()) LatLng(latitude, longitude) else null
595
+ }
596
+ }
597
+
598
+ private fun region(value: JSONObject?): LatLngBounds? {
599
+ val center = value?.optJSONObject("center") ?: return null
600
+ val span = value.optJSONObject("span") ?: return null
601
+ val latitude = center.optDouble("latitude", Double.NaN)
602
+ val longitude = center.optDouble("longitude", Double.NaN)
603
+ val latitudeDelta = span.optDouble("latitudeDelta", span.optDouble("latitude_delta", Double.NaN))
604
+ val longitudeDelta = span.optDouble("longitudeDelta", span.optDouble("longitude_delta", Double.NaN))
605
+ if (!latitude.isFinite() || !longitude.isFinite() || !latitudeDelta.isFinite() || !longitudeDelta.isFinite()) return null
606
+ return LatLngBounds(LatLng(latitude - latitudeDelta / 2, longitude - longitudeDelta / 2), LatLng(latitude + latitudeDelta / 2, longitude + longitudeDelta / 2))
607
+ }
608
+
609
+ private fun color(value: String): Int = runCatching { Color.parseColor(value) }.getOrDefault(Color.BLUE)
610
+ }
611
+
462
612
  /** A deterministic flex-shaped container shared by normal views and scroll content. */
463
613
  private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
464
614
  var onLayoutChanged: (() -> Unit)? = null
@@ -474,6 +624,46 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
474
624
  private var gridAutoRows: CraftNativeLength? = null
475
625
  private var reverse = false
476
626
  private var wrapReverse = false
627
+ private var authoredPadding = intArrayOf(0, 0, 0, 0)
628
+ private var safeAreaPadding = intArrayOf(0, 0, 0, 0)
629
+ private var applyingPadding = false
630
+ private var safeAreaEdges = setOf("top", "right", "bottom", "left")
631
+
632
+ override fun setPadding(left: Int, top: Int, right: Int, bottom: Int) {
633
+ if (!applyingPadding) authoredPadding = intArrayOf(left, top, right, bottom)
634
+ applyingPadding = true
635
+ super.setPadding(
636
+ left + safeAreaPadding[0],
637
+ top + safeAreaPadding[1],
638
+ right + safeAreaPadding[2],
639
+ bottom + safeAreaPadding[3],
640
+ )
641
+ applyingPadding = false
642
+ }
643
+
644
+ fun setSafeAreaEdges(raw: JSONArray?) {
645
+ safeAreaEdges = if (raw == null) {
646
+ setOf("top", "right", "bottom", "left")
647
+ } else {
648
+ (0 until raw.length()).mapNotNull { raw.optString(it).takeIf { edge -> edge in setOf("top", "right", "bottom", "left") } }.toSet()
649
+ }
650
+ applySafeAreaPadding()
651
+ }
652
+
653
+ fun setWindowSafeInsets(left: Int, top: Int, right: Int, bottom: Int) {
654
+ val insets = intArrayOf(left, top, right, bottom)
655
+ safeAreaPadding = intArrayOf(
656
+ if ("left" in safeAreaEdges) insets[0] else 0,
657
+ if ("top" in safeAreaEdges) insets[1] else 0,
658
+ if ("right" in safeAreaEdges) insets[2] else 0,
659
+ if ("bottom" in safeAreaEdges) insets[3] else 0,
660
+ )
661
+ applySafeAreaPadding()
662
+ }
663
+
664
+ private fun applySafeAreaPadding() {
665
+ setPadding(authoredPadding[0], authoredPadding[1], authoredPadding[2], authoredPadding[3])
666
+ }
477
667
 
478
668
  /**
479
669
  * What a child measures at a pair of specs, remembered for the frame.
@@ -643,7 +833,11 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
643
833
  if (index > 0 && lineIds != null && lineIds[index] != lineIds[index - 1]) {
644
834
  lines += line
645
835
  line = Line()
646
- } else if (index > 0 && lineIds == null && wrap && line.entries.isNotEmpty() && line.main + gap + childMain > availableMain) {
836
+ } else if (index > 0 && lineIds == null && wrap && line.entries.isNotEmpty() && line.main + gap + childMain > availableMain + 0.5f * (line.entries.size + 1)) {
837
+ // Each size is rounded to whole pixels, up to half a pixel
838
+ // over: two 60-point items fill a 120-point row exactly
839
+ // in points, and at 2.625 pixels per point (157.5 + 157.5
840
+ // in 315) rounding alone must not wrap the second.
647
841
  lines += line
648
842
  line = Line()
649
843
  }
@@ -1343,6 +1537,18 @@ private class CraftNativeScrollView(context: Context) : SwipeRefreshLayout(conte
1343
1537
  if (handler == null) isRefreshing = false
1344
1538
  }
1345
1539
 
1540
+ fun setRefreshAppearance(props: JSONObject, density: Float) {
1541
+ val tint = props.optString("tintColor").takeIf { it.isNotBlank() }
1542
+ if (tint != null) setColorSchemeColors(craftNativeColor(tint))
1543
+ else setColorSchemeColors(Color.WHITE)
1544
+ val background = props.optString("progressBackgroundColor").takeIf { it.isNotBlank() }
1545
+ if (background != null) setProgressBackgroundColorSchemeColor(craftNativeColor(background))
1546
+ else setProgressBackgroundColorSchemeColor(Color.TRANSPARENT)
1547
+ val offset = props.optDouble("progressViewOffset", Double.NaN)
1548
+ if (offset.isFinite()) setProgressViewOffset(true, 0, (offset * density).roundToInt())
1549
+ else setProgressViewOffset(false, 0, 0)
1550
+ }
1551
+
1346
1552
  override fun onDetachedFromWindow() {
1347
1553
  refreshHandler = null
1348
1554
  isRefreshing = false
@@ -1783,12 +1989,15 @@ private class CraftNativeDatePickerView(context: Context) : FrameLayout(context)
1783
1989
 
1784
1990
  private fun changed() {
1785
1991
  if (suppressChanges) return
1786
- val picker = timePicker ?: return
1787
- val snapped = ((picker.minute + minuteInterval / 2) / minuteInterval * minuteInterval).coerceAtMost(59)
1788
- if (snapped != picker.minute) {
1789
- suppressChanges = true
1790
- picker.minute = snapped
1791
- suppressChanges = false
1992
+ // A date-only picker has no time half to snap, but its change is
1993
+ // still the screen's to hear.
1994
+ timePicker?.let { picker ->
1995
+ val snapped = ((picker.minute + minuteInterval / 2) / minuteInterval * minuteInterval).coerceAtMost(59)
1996
+ if (snapped != picker.minute) {
1997
+ suppressChanges = true
1998
+ picker.minute = snapped
1999
+ suppressChanges = false
2000
+ }
1792
2001
  }
1793
2002
  onValueChanged?.invoke()
1794
2003
  }
@@ -2182,6 +2391,9 @@ class MainActivity : AppCompatActivity() {
2182
2391
  ) {
2183
2392
  /** What the screen set with `craft.navigation.setOptions`, merged. */
2184
2393
  var options = JSONObject()
2394
+ /** Overrides from a native `<StatusBar>` node, reset on each render. */
2395
+ var statusBarStyle: String? = null
2396
+ var statusBarHidden: Boolean? = null
2185
2397
  /** Shown before: its next showing is a `SCREEN_FOCUS`. */
2186
2398
  var shown = false
2187
2399
  /** The app path it was opened for (`/m/workout/42`), to find it again. */
@@ -2209,6 +2421,7 @@ class MainActivity : AppCompatActivity() {
2209
2421
  val nodeViews = mutableMapOf<String, View>()
2210
2422
  val nodePaths = mutableMapOf<String, String>()
2211
2423
  val handlers = mutableMapOf<View, String>()
2424
+ val linkTargets = mutableMapOf<View, String>()
2212
2425
  val pressInHandlers = mutableMapOf<View, String>()
2213
2426
  val pressOutHandlers = mutableMapOf<View, String>()
2214
2427
  val longPressHandlers = mutableMapOf<View, String>()
@@ -2279,6 +2492,9 @@ class MainActivity : AppCompatActivity() {
2279
2492
  private val tabStacks = mutableListOf<MutableList<Screen>>()
2280
2493
  private var currentTab = 0
2281
2494
  private var visibleScreen: Screen? = null
2495
+ private var tabSwipeTarget: Screen? = null
2496
+ private var tabSwipeTargetIndex = -1
2497
+ private var tabSwipeDirection = 0
2282
2498
  /** The app script's isolate (`nativeApp`), when the app has one. */
2283
2499
  private var appScreen: Screen? = null
2284
2500
  private val urlSchemes = mutableSetOf<String>()
@@ -2298,6 +2514,7 @@ class MainActivity : AppCompatActivity() {
2298
2514
  private var initialDeepLink: Uri? = null
2299
2515
  private var initialDeepLinkClaimed = false
2300
2516
  private var defaultTextColor = Color.WHITE
2517
+ private var windowSafeInsets = intArrayOf(0, 0, 0, 0)
2301
2518
  private var useMessagePorts = false
2302
2519
  private val imageExecutor = Executors.newFixedThreadPool(2)
2303
2520
  /** Lays out each isolate's prelude (the synchronous stores) off the main thread. */
@@ -2330,8 +2547,9 @@ class MainActivity : AppCompatActivity() {
2330
2547
  ViewCompat.setOnApplyWindowInsetsListener(root) { view, insets ->
2331
2548
  val safe = insets.getInsets(WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout())
2332
2549
  val ime = insets.getInsets(WindowInsetsCompat.Type.ime())
2550
+ windowSafeInsets = intArrayOf(safe.left, safe.top, safe.right, safe.bottom)
2333
2551
  keyboardInset = max(0, ime.bottom - safe.bottom)
2334
- view.setPadding(safe.left, safe.top, safe.right, safe.bottom)
2552
+ updateSafeAreaViews()
2335
2553
  updateKeyboardAvoidingViews()
2336
2554
  view.requestLayout()
2337
2555
  view.post { screens.lastOrNull()?.takeIf { it.active }?.let(::emitLayoutEvents) }
@@ -2443,6 +2661,9 @@ class MainActivity : AppCompatActivity() {
2443
2661
  haptic()
2444
2662
  if (index == currentTab) popToRoot() else selectTab(index)
2445
2663
  }
2664
+ bars.onTabSwipeStart = { dx, x, y -> beginTabSwipe(dx, x, y) }
2665
+ bars.onTabSwipeMove = { dx -> updateTabSwipe(dx) }
2666
+ bars.onTabSwipeEnd = { dx, cancelled -> finishTabSwipe(dx, cancelled) }
2446
2667
  root.addView(bars)
2447
2668
  chrome = bars
2448
2669
  tabStacks.clear()
@@ -2466,12 +2687,97 @@ class MainActivity : AppCompatActivity() {
2466
2687
  currentTab = index
2467
2688
  screens = tabStacks[index]
2468
2689
  chrome?.selectTab(index)
2469
- if (screens.isEmpty()) {
2470
- val match = routes.tabs.getOrNull(index)?.let { routes.match(it.id) }
2471
- if (match != null) openScreen(match.screen, match.params)?.path = match.path else openScreen(null)
2472
- } else {
2473
- show(screens.last())
2690
+ ensureTabRoot(index)?.let(::show)
2691
+ }
2692
+
2693
+ /** Mount a tab's root without stealing focus, for an interactive swipe. */
2694
+ private fun ensureTabRoot(index: Int): Screen? {
2695
+ if (index !in tabStacks.indices) return null
2696
+ tabStacks[index].lastOrNull()?.let { return it }
2697
+ val previous = screens
2698
+ screens = tabStacks[index]
2699
+ val match = routes.tabs.getOrNull(index)?.let { routes.match(it.id) }
2700
+ val screen = if (match != null) openScreen(match.screen, match.params, showImmediately = false) else openScreen(null, showImmediately = false)
2701
+ screen?.path = match?.path
2702
+ screens = previous
2703
+ return screen
2704
+ }
2705
+
2706
+ /** Start an interactive horizontal transition to the adjacent tab. */
2707
+ private fun beginTabSwipe(dx: Float, x: Float, y: Float): Boolean {
2708
+ if (tabSwipeTarget != null || screens.size != 1 || chrome?.tabSwipeEnabled != true) return false
2709
+ val direction = if (dx < 0) 1 else -1
2710
+ val targetIndex = currentTab + direction
2711
+ val current = visibleScreen ?: return false
2712
+ val target = ensureTabRoot(targetIndex) ?: return false
2713
+ val host = chrome?.content ?: return false
2714
+ if (target === current || host.width <= 0) return false
2715
+ if (horizontalScrollCanConsume(current.view, host, x, y, dx)) return false
2716
+ (target.view.parent as? ViewGroup)?.removeView(target.view)
2717
+ host.addView(target.view, FrameLayout.LayoutParams(-1, -1))
2718
+ target.view.translationX = if (direction > 0) host.width.toFloat() else -host.width.toFloat()
2719
+ current.view.translationX = 0f
2720
+ host.bringChildToFront(target.view)
2721
+ tabSwipeTarget = target
2722
+ tabSwipeTargetIndex = targetIndex
2723
+ tabSwipeDirection = direction
2724
+ chrome?.selectTab(targetIndex)
2725
+ return true
2726
+ }
2727
+
2728
+ private fun updateTabSwipe(dx: Float) {
2729
+ val current = visibleScreen ?: return
2730
+ val target = tabSwipeTarget ?: return
2731
+ val width = (chrome?.content?.width ?: return).toFloat().coerceAtLeast(1f)
2732
+ val offset = dx.coerceIn(-width, width)
2733
+ target.view.translationX = (if (tabSwipeDirection > 0) width else -width) + offset
2734
+ current.view.translationX = offset
2735
+ }
2736
+
2737
+ private fun finishTabSwipe(dx: Float, cancelled: Boolean) {
2738
+ val current = visibleScreen ?: return
2739
+ val target = tabSwipeTarget ?: return
2740
+ val host = chrome?.content ?: return
2741
+ val width = host.width.toFloat().coerceAtLeast(1f)
2742
+ val complete = !cancelled && kotlin.math.abs(dx) >= width * 0.28f
2743
+ val endOffset = if (complete) {
2744
+ if (tabSwipeDirection > 0) -width else width
2745
+ } else 0f
2746
+ val targetOffset = if (complete) 0f else if (tabSwipeDirection > 0) width else -width
2747
+ current.view.animate().translationX(endOffset).setDuration(180).start()
2748
+ target.view.animate().translationX(targetOffset).setDuration(180).withEndAction {
2749
+ target.view.animate().setListener(null)
2750
+ if (complete) {
2751
+ current.view.translationX = 0f
2752
+ currentTab = tabSwipeTargetIndex
2753
+ screens = tabStacks[currentTab]
2754
+ tabSwipeTarget = null
2755
+ tabSwipeTargetIndex = -1
2756
+ tabSwipeDirection = 0
2757
+ haptic()
2758
+ show(target)
2759
+ } else {
2760
+ host.removeView(target.view)
2761
+ current.view.translationX = 0f
2762
+ chrome?.selectTab(currentTab)
2763
+ tabSwipeTarget = null
2764
+ tabSwipeTargetIndex = -1
2765
+ tabSwipeDirection = 0
2766
+ }
2767
+ }.start()
2768
+ }
2769
+
2770
+ private fun horizontalScrollCanConsume(view: View, host: ViewGroup, x: Float, y: Float, dx: Float): Boolean {
2771
+ val localX = x - view.left
2772
+ val localY = y - host.top - view.top
2773
+ if (view !is ViewGroup || localX < 0 || localY < 0 || localX > view.width || localY > view.height) return false
2774
+ for (index in view.childCount - 1 downTo 0) {
2775
+ val child = view.getChildAt(index)
2776
+ if (!child.isShown || localX < child.left || localX > child.right || localY < child.top || localY > child.bottom) continue
2777
+ if (child is android.widget.HorizontalScrollView && child.canScrollHorizontally(if (dx < 0) 1 else -1)) return true
2778
+ if (horizontalScrollCanConsume(child, host, localX - child.left, localY - child.top, dx)) return true
2474
2779
  }
2780
+ return false
2475
2781
  }
2476
2782
 
2477
2783
  /**
@@ -2523,7 +2829,7 @@ class MainActivity : AppCompatActivity() {
2523
2829
  services.perform("Browser", "open", JSONArray().put(url), {})
2524
2830
  }
2525
2831
 
2526
- private fun openScreen(route: String?, params: JSONObject = JSONObject(), headless: Boolean = false): Screen? {
2832
+ private fun openScreen(route: String?, params: JSONObject = JSONObject(), headless: Boolean = false, showImmediately: Boolean = true): Screen? {
2527
2833
  val engine = sandbox ?: return null
2528
2834
  val isolate = engine.createIsolate()
2529
2835
  val view = CraftNativeFlexLayout(this).apply {
@@ -2560,7 +2866,7 @@ class MainActivity : AppCompatActivity() {
2560
2866
  }
2561
2867
  if (!headless) {
2562
2868
  screens.add(activeScreen)
2563
- show(activeScreen)
2869
+ if (showImmediately) show(activeScreen)
2564
2870
  }
2565
2871
  val routePrelude = """
2566
2872
  globalThis.__stxNativeRoute = ${route?.let { JSONObject.quote(it) } ?: "undefined"};
@@ -2844,6 +3150,23 @@ class MainActivity : AppCompatActivity() {
2844
3150
  else -> null
2845
3151
  }
2846
3152
  }.forEach { it.refreshThemeDefaults() }
3153
+ updateSafeAreaViews()
3154
+ }
3155
+
3156
+ private fun updateSafeAreaViews() {
3157
+ if (!::root.isInitialized) return
3158
+ val active = visibleScreen ?: screens.lastOrNull { it.active }
3159
+ val rootNode = active?.mutations?.node("root")
3160
+ val safeRoot = rootNode?.let { canonicalType(it.optString("type")) == "SafeAreaView" } == true
3161
+ root.setPadding(
3162
+ if (safeRoot) 0 else windowSafeInsets[0],
3163
+ if (safeRoot) 0 else windowSafeInsets[1],
3164
+ if (safeRoot) 0 else windowSafeInsets[2],
3165
+ if (safeRoot) 0 else windowSafeInsets[3],
3166
+ )
3167
+ val tree = active?.view?.getChildAt(0) as? CraftNativeFlexLayout
3168
+ if (safeRoot) tree?.setWindowSafeInsets(windowSafeInsets[0], windowSafeInsets[1], windowSafeInsets[2], windowSafeInsets[3])
3169
+ else tree?.setWindowSafeInsets(0, 0, 0, 0)
2847
3170
  }
2848
3171
 
2849
3172
  private fun refreshThemeDefaults() {
@@ -3061,6 +3384,8 @@ class MainActivity : AppCompatActivity() {
3061
3384
 
3062
3385
  private fun renderCommitted(screen: Screen, document: JSONObject) {
3063
3386
  // Keep live controls (especially the focused EditText) across renders.
3387
+ screen.statusBarStyle = null
3388
+ screen.statusBarHidden = null
3064
3389
  val next = mutableMapOf<String, View>()
3065
3390
  screen.nodeViews.clear()
3066
3391
  screen.nodePaths.clear()
@@ -3075,10 +3400,24 @@ class MainActivity : AppCompatActivity() {
3075
3400
  screen.controls.putAll(next)
3076
3401
  screen.handlers.keys.retainAll(retainedViews(screen))
3077
3402
  screen.view.setChildStyle(tree, document.optJSONObject("style") ?: JSONObject(), resources.displayMetrics.density)
3403
+ updateSafeAreaViews()
3078
3404
  // A full render can change a view's custom layout style without
3079
3405
  // changing the root bounds. Ensure native @layout observers still see
3080
3406
  // the settled child frame after the next traversal.
3081
3407
  scheduleLayoutEvents(screen)
3408
+ if (visibleScreen === screen) applyStatusBar(screen)
3409
+ }
3410
+
3411
+ private fun applyStatusBar(screen: Screen) {
3412
+ val controller = WindowCompat.getInsetsController(window, window.decorView)
3413
+ val night = (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
3414
+ controller.isAppearanceLightStatusBars = when (screen.statusBarStyle?.lowercase()) {
3415
+ "light-content", "light" -> false
3416
+ "dark-content", "dark" -> true
3417
+ else -> !night
3418
+ }
3419
+ if (screen.statusBarHidden == true) controller.hide(WindowInsetsCompat.Type.statusBars())
3420
+ else controller.show(WindowInsetsCompat.Type.statusBars())
3082
3421
  }
3083
3422
 
3084
3423
  private fun clearRenderedTree(screen: Screen) {
@@ -3088,6 +3427,7 @@ class MainActivity : AppCompatActivity() {
3088
3427
  screen.nodeViews.clear()
3089
3428
  screen.nodePaths.clear()
3090
3429
  screen.handlers.clear()
3430
+ screen.linkTargets.clear()
3091
3431
  screen.flatListRows.clear()
3092
3432
  screen.flatListOwners.clear()
3093
3433
  screen.flatListOwnerByView.clear()
@@ -3124,6 +3464,14 @@ class MainActivity : AppCompatActivity() {
3124
3464
  }
3125
3465
  configureText(this, childText(children), style)
3126
3466
  }
3467
+ "StatusBar" -> (previous as? CraftNativeFlexLayout ?: CraftNativeFlexLayout(this)).apply {
3468
+ screen.statusBarStyle = props.optString("barStyle").takeIf { it.isNotBlank() }
3469
+ screen.statusBarHidden = if (props.has("hidden")) props.optBoolean("hidden") else null
3470
+ visibility = View.GONE
3471
+ }
3472
+ "RefreshControl" -> (previous as? CraftNativeFlexLayout ?: CraftNativeFlexLayout(this)).apply {
3473
+ visibility = View.GONE
3474
+ }
3127
3475
  "Link" -> ((previous as? TextView)?.takeUnless { it is Button } ?: TextView(this)).apply {
3128
3476
  includeFontPadding = false
3129
3477
  configureText(this, props.optString("title").ifBlank { childText(children) }, style)
@@ -3133,9 +3481,15 @@ class MainActivity : AppCompatActivity() {
3133
3481
  isEnabled = !props.optBoolean("disabled", false)
3134
3482
  isClickable = true
3135
3483
  isFocusable = true
3484
+ val target = listOf(props.optString("href"), props.optString("to"))
3485
+ .firstOrNull { it.isNotBlank() }
3486
+ ?.trim()
3487
+ ?.takeIf { !props.optBoolean("disabled", false) }
3488
+ if (target != null) screen.linkTargets[this] = target else screen.linkTargets.remove(this)
3136
3489
  val handler = events.optString("onPress").ifBlank { events.optString("onClick") }
3137
3490
  if (handler.isNotBlank()) screen.handlers[this] = handler else screen.handlers.remove(this)
3138
3491
  setOnClickListener {
3492
+ screen.linkTargets[it]?.let { target -> navigateOpen(target); return@setOnClickListener }
3139
3493
  val current = screen.handlers[this] ?: return@setOnClickListener
3140
3494
  send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject()))
3141
3495
  }
@@ -3540,7 +3894,7 @@ class MainActivity : AppCompatActivity() {
3540
3894
  if (onLoadEnd.isNotBlank()) screen.imageLoadEndHandlers[this] = onLoadEnd else screen.imageLoadEndHandlers.remove(this)
3541
3895
  val onError = events.optString("onError")
3542
3896
  if (onError.isNotBlank()) screen.imageErrorHandlers[this] = onError else screen.imageErrorHandlers.remove(this)
3543
- updateImage(screen, this, props.opt("source"))
3897
+ updateImage(screen, this, props.opt("source"), props.opt("defaultSource"))
3544
3898
  }
3545
3899
  "ImageBackground" -> (previous as? CraftNativeImageBackgroundView ?: CraftNativeImageBackgroundView(this)).apply {
3546
3900
  image.scaleType = when (style.optString("resizeMode").ifBlank { props.optString("resizeMode") }) {
@@ -3557,7 +3911,7 @@ class MainActivity : AppCompatActivity() {
3557
3911
  if (onLoadEnd.isNotBlank()) screen.imageLoadEndHandlers[image] = onLoadEnd else screen.imageLoadEndHandlers.remove(image)
3558
3912
  val onError = events.optString("onError")
3559
3913
  if (onError.isNotBlank()) screen.imageErrorHandlers[image] = onError else screen.imageErrorHandlers.remove(image)
3560
- updateImage(screen, image, props.opt("source"))
3914
+ updateImage(screen, image, props.opt("source"), props.opt("defaultSource"))
3561
3915
  configureContainer(content, style)
3562
3916
  reconcileChildren(screen, content, children, path, next, style, previousControls)
3563
3917
  }
@@ -3571,6 +3925,13 @@ class MainActivity : AppCompatActivity() {
3571
3925
  controls = props.optBoolean("controls", false),
3572
3926
  )
3573
3927
  }
3928
+ "Map" -> (previous as? CraftNativeMapView ?: CraftNativeMapView(this)).apply {
3929
+ configure(props, children) { markerId ->
3930
+ markerHandler(markerId)?.let { handler ->
3931
+ send(screen, "EVENT", JSONObject().put("handlerName", handler).put("nativeEvent", JSONObject().put("id", markerId)))
3932
+ }
3933
+ }
3934
+ }
3574
3935
  "Svg" -> (previous as? CraftNativeSvgView ?: CraftNativeSvgView(this)).apply {
3575
3936
  drawing = node
3576
3937
  }
@@ -3590,10 +3951,18 @@ class MainActivity : AppCompatActivity() {
3590
3951
  props.optBoolean("alwaysBounceHorizontal", horizontal),
3591
3952
  )
3592
3953
  setPagingEnabled(props.optBoolean("pagingEnabled", false))
3593
- val refreshHandler = events.optString("onRefresh").takeIf { it.isNotBlank() }?.let { handler ->
3954
+ val refreshNode = (0 until children.length()).asSequence()
3955
+ .mapNotNull { children.optJSONObject(it) }
3956
+ .firstOrNull { canonicalType(it.optString("type")) == "RefreshControl" }
3957
+ val refreshProps = refreshNode?.optJSONObject("props") ?: JSONObject()
3958
+ val refreshEvents = refreshNode?.optJSONObject("events") ?: JSONObject()
3959
+ val refreshHandlerName = refreshEvents.optString("onRefresh").takeIf { it.isNotBlank() }
3960
+ ?: events.optString("onRefresh").takeIf { it.isNotBlank() }
3961
+ val refreshHandler = refreshHandlerName?.let { handler ->
3594
3962
  { send(screen, "EVENT", JSONObject().put("handlerName", handler).put("nativeEvent", JSONObject())) }
3595
3963
  }
3596
- setRefreshHandler(refreshHandler, props.optBoolean("refreshing", false))
3964
+ setRefreshHandler(refreshHandler, if (refreshNode == null) props.optBoolean("refreshing", false) else refreshProps.optBoolean("refreshing", false))
3965
+ setRefreshAppearance(refreshProps, resources.displayMetrics.density)
3597
3966
  val onScroll = events.optString("onScroll")
3598
3967
  if (onScroll.isNotBlank()) screen.scrollHandlers[this] = onScroll else screen.scrollHandlers.remove(this)
3599
3968
  val onBegin = events.optString("onScrollBeginDrag")
@@ -3647,7 +4016,12 @@ class MainActivity : AppCompatActivity() {
3647
4016
  }
3648
4017
  }
3649
4018
  configureContainer(content, contentStyle)
3650
- reconcileChildren(screen, content, children, path, next, contentStyle, previousControls)
4019
+ val contentChildren = JSONArray()
4020
+ for (index in 0 until children.length()) {
4021
+ val child = children.opt(index)
4022
+ if (child !is JSONObject || canonicalType(child.optString("type")) != "RefreshControl") contentChildren.put(child)
4023
+ }
4024
+ reconcileChildren(screen, content, contentChildren, path, next, contentStyle, previousControls)
3651
4025
  }
3652
4026
  "FlatList" -> (previous as? CraftNativeRefreshableFlatList ?: CraftNativeRefreshableFlatList(this)).apply {
3653
4027
  val nativeList = list
@@ -3665,6 +4039,7 @@ class MainActivity : AppCompatActivity() {
3665
4039
  }
3666
4040
  "View", "SafeAreaView" -> (previous as? CraftNativeFlexLayout ?: CraftNativeFlexLayout(this)).apply {
3667
4041
  configureContainer(this, style)
4042
+ if (type == "SafeAreaView") setSafeAreaEdges(props.optJSONArray("edges"))
3668
4043
  reconcileChildren(screen, this, children, path, next, style, previousControls)
3669
4044
  }
3670
4045
  "KeyboardAvoidingView" -> (previous as? CraftNativeKeyboardAvoidingView ?: CraftNativeKeyboardAvoidingView(this)).apply {
@@ -3686,15 +4061,21 @@ class MainActivity : AppCompatActivity() {
3686
4061
  val pressOutHandler = events.optString("onPressOut").takeIf { it.isNotBlank() }
3687
4062
  if (pressInHandler != null) screen.pressInHandlers[control] = pressInHandler else screen.pressInHandlers.remove(control)
3688
4063
  if (pressOutHandler != null) screen.pressOutHandlers[control] = pressOutHandler else screen.pressOutHandlers.remove(control)
3689
- if (type != "Button" && type != "Link" && type != "TextInput" && type != "Picker" && type != "DatePicker" && type != "Modal" && type != "Switch" && type != "Slider" && type != "ActivityIndicator" && type != "KeyboardAvoidingView") {
4064
+ if (type != "Button" && type != "Link" && type != "TextInput" && type != "Picker" && type != "DatePicker" && type != "Modal" && type != "Switch" && type != "Slider" && type != "ActivityIndicator" && type != "KeyboardAvoidingView" && type != "StatusBar" && type != "RefreshControl" && type != "Map") {
3690
4065
  val handler = events.optString("onPress").ifBlank { events.optString("onClick") }
3691
4066
  val disabled = props.optBoolean("disabled", false)
4067
+ val target = listOf(props.optString("href"), props.optString("to"))
4068
+ .firstOrNull { it.isNotBlank() }
4069
+ ?.trim()
4070
+ ?.takeIf { !disabled && type in setOf("TouchableOpacity", "TouchableHighlight", "Pressable") }
4071
+ if (target != null) screen.linkTargets[control] = target else screen.linkTargets.remove(control)
3692
4072
  val hasPressLifecycle = pressInHandler != null || pressOutHandler != null
3693
- if ((handler.isNotBlank() || (type in setOf("TouchableOpacity", "TouchableHighlight", "Pressable") && hasPressLifecycle)) && !disabled) {
4073
+ if ((handler.isNotBlank() || target != null || (type in setOf("TouchableOpacity", "TouchableHighlight", "Pressable") && hasPressLifecycle)) && !disabled) {
3694
4074
  if (handler.isNotBlank()) screen.handlers[control] = handler else screen.handlers.remove(control)
3695
4075
  control.isClickable = true
3696
4076
  if (type in setOf("TouchableOpacity", "TouchableHighlight", "Pressable")) {
3697
4077
  control.setOnClickListener {
4078
+ screen.linkTargets[it]?.let { target -> navigateOpen(target); return@setOnClickListener }
3698
4079
  val current = screen.handlers[it] ?: return@setOnClickListener
3699
4080
  send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject()))
3700
4081
  }
@@ -3715,6 +4096,7 @@ class MainActivity : AppCompatActivity() {
3715
4096
  send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject()))
3716
4097
  }
3717
4098
  if (event.actionMasked == android.view.MotionEvent.ACTION_UP) {
4099
+ screen.linkTargets[view]?.let { target -> navigateOpen(target); return@setOnTouchListener true }
3718
4100
  val current = screen.handlers[view] ?: return@setOnTouchListener true
3719
4101
  send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject()))
3720
4102
  }
@@ -3726,6 +4108,7 @@ class MainActivity : AppCompatActivity() {
3726
4108
  } else {
3727
4109
  control.setOnTouchListener(null)
3728
4110
  control.setOnClickListener {
4111
+ screen.linkTargets[control]?.let { target -> navigateOpen(target); return@setOnClickListener }
3729
4112
  val current = screen.handlers[control] ?: return@setOnClickListener
3730
4113
  send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject()))
3731
4114
  }
@@ -3833,10 +4216,13 @@ class MainActivity : AppCompatActivity() {
3833
4216
  "switch" -> "Switch"
3834
4217
  "slider" -> "Slider"
3835
4218
  "activityindicator" -> "ActivityIndicator"
4219
+ "statusbar" -> "StatusBar"
4220
+ "refreshcontrol" -> "RefreshControl"
3836
4221
  "image" -> "Image"
3837
4222
  "imagebackground" -> "ImageBackground"
3838
4223
  "keyboardavoidingview" -> "KeyboardAvoidingView"
3839
4224
  "video" -> "Video"
4225
+ "map" -> "Map"
3840
4226
  "icon" -> "Icon"
3841
4227
  "scrollview" -> "ScrollView"
3842
4228
  "sectionlist" -> "SectionList"
@@ -4274,6 +4660,21 @@ class MainActivity : AppCompatActivity() {
4274
4660
 
4275
4661
  private fun applyTextDecoration(view: TextView, style: JSONObject) {
4276
4662
  val line = style.optString("textDecorationLine")
4663
+ if (view is EditText) {
4664
+ // A field's own text, spanned in place: replacing it would move
4665
+ // the caret to the start and tell the screen the text changed.
4666
+ val editable = view.text ?: return
4667
+ editable.getSpans(0, editable.length, CraftTextDecorationSpan::class.java).forEach(editable::removeSpan)
4668
+ if (line.isBlank() || line == "none" || editable.isEmpty()) return
4669
+ val fieldColor = style.optString("textDecorationColor").takeIf { it.isNotBlank() }?.let { color(it, Color.TRANSPARENT) }
4670
+ editable.setSpan(
4671
+ CraftTextDecorationSpan(line, style.optString("textDecorationStyle").ifBlank { "solid" }, fieldColor),
4672
+ 0,
4673
+ editable.length,
4674
+ Spannable.SPAN_INCLUSIVE_INCLUSIVE,
4675
+ )
4676
+ return
4677
+ }
4277
4678
  val text = view.text?.toString() ?: return
4278
4679
  if (line.isBlank() || line == "none" || text.isEmpty()) {
4279
4680
  if (view.text is Spannable) view.text = text
@@ -4311,12 +4712,23 @@ class MainActivity : AppCompatActivity() {
4311
4712
  host == "::1" || host.endsWith(".localhost")
4312
4713
  }
4313
4714
 
4314
- private fun updateImage(screen: Screen, view: ImageView, source: Any?) {
4315
- val uri = when (source) {
4316
- is String -> source
4317
- is JSONObject -> source.optString("uri")
4318
- else -> ""
4715
+ private fun imageUri(source: Any?): String = when (source) {
4716
+ is String -> source
4717
+ is JSONObject -> source.optString("uri")
4718
+ else -> ""
4719
+ }
4720
+
4721
+ private fun immediateImage(uri: String): Bitmap? {
4722
+ if (uri.startsWith("data:image/") && uri.contains(',')) {
4723
+ val bytes = runCatching { Base64.decode(uri.substringAfter(','), Base64.DEFAULT) }.getOrNull()
4724
+ return bytes?.let { BitmapFactory.decodeByteArray(it, 0, it.size) }
4319
4725
  }
4726
+ if (!uri.contains(':')) return runCatching { assets.open(uri).use { BitmapFactory.decodeStream(it) } }.getOrNull()
4727
+ return null
4728
+ }
4729
+
4730
+ private fun updateImage(screen: Screen, view: ImageView, source: Any?, defaultSource: Any? = null) {
4731
+ val uri = imageUri(source)
4320
4732
  if (uri.isBlank()) {
4321
4733
  if (imageSources[view] == "") return
4322
4734
  imageJobs.remove(view)?.cancel(true)
@@ -4331,6 +4743,7 @@ class MainActivity : AppCompatActivity() {
4331
4743
  imageSources[view] = uri
4332
4744
  imageErrors.remove(view)
4333
4745
  view.setImageDrawable(null)
4746
+ immediateImage(imageUri(defaultSource))?.let(view::setImageBitmap)
4334
4747
  emitImageEvent(screen, view, screen.imageLoadStartHandlers[view])
4335
4748
 
4336
4749
  if (uri.startsWith("data:image/") && uri.contains(',')) {
@@ -4847,6 +5260,7 @@ class MainActivity : AppCompatActivity() {
4847
5260
  if (!preservingInputDrafts) identity?.let(screen.drafts::remove)
4848
5261
  }
4849
5262
  released.forEach(defaultBackgrounds::remove)
5263
+ screen.linkTargets.keys.removeAll(released)
4850
5264
  screen.handlers.keys.removeAll(released)
4851
5265
  screen.pressInHandlers.keys.removeAll(released)
4852
5266
  screen.pressOutHandlers.keys.removeAll(released)