craft-native 0.0.158 → 0.0.160

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.
@@ -45,11 +45,14 @@ import android.view.ViewTreeObserver
45
45
  import android.widget.AdapterView
46
46
  import android.widget.ArrayAdapter
47
47
  import android.widget.Button
48
+ import android.widget.CheckBox
49
+ import android.widget.DatePicker
48
50
  import android.widget.EditText
49
51
  import android.widget.FrameLayout
50
52
  import android.widget.HorizontalScrollView
51
53
  import android.widget.ImageView
52
54
  import android.widget.LinearLayout
55
+ import android.widget.TimePicker
53
56
  import android.widget.ScrollView
54
57
  import android.widget.SeekBar
55
58
  import android.widget.Spinner
@@ -76,6 +79,9 @@ import com.craft.runtime.CraftNative
76
79
  import org.json.JSONArray
77
80
  import org.json.JSONObject
78
81
  import java.net.URL
82
+ import java.text.SimpleDateFormat
83
+ import java.util.Calendar
84
+ import java.util.Locale
79
85
  import java.util.concurrent.Executors
80
86
  import java.util.concurrent.Future
81
87
  import java.util.UUID
@@ -389,7 +395,7 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
389
395
  private var columnGap = 0
390
396
  private var grid = false
391
397
  private var gridColumns = 1
392
- private var gridAutoRows: Int? = null
398
+ private var gridAutoRows: CraftNativeLength? = null
393
399
  private var reverse = false
394
400
 
395
401
  fun configure(style: JSONObject, density: Float) {
@@ -406,7 +412,7 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
406
412
  gridColumns = craftNativeGridColumnCount(
407
413
  if (style.has("gridTemplateColumns")) style.opt("gridTemplateColumns") else style.opt("gridColumns")
408
414
  )
409
- gridAutoRows = style.optDouble("gridAutoRows", Double.NaN).takeUnless { it.isNaN() }?.let { (it * density).toInt() }
415
+ gridAutoRows = CraftNativeLength.from(style.opt("gridAutoRows"), density)
410
416
  val padding = (style.optDouble("padding", 0.0) * density).toInt()
411
417
  val horizontal = (style.optDouble("paddingHorizontal", padding.toDouble() / density.toDouble()) * density).toInt()
412
418
  val vertical = (style.optDouble("paddingVertical", padding.toDouble() / density.toDouble()) * density).toInt()
@@ -534,7 +540,7 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
534
540
  }
535
541
  }
536
542
  val rowHeights = rows.map { row ->
537
- max(gridAutoRows ?: 0, row.maxOfOrNull { entry ->
543
+ max(gridAutoRows?.resolve(height - paddingTop - paddingBottom) ?: 0, row.maxOfOrNull { entry ->
538
544
  val lp = entry.view.layoutParams as? MarginLayoutParams
539
545
  entry.height + (lp?.topMargin ?: 0) + (lp?.bottomMargin ?: 0)
540
546
  } ?: 0)
@@ -559,7 +565,7 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
559
565
  val childHeight = measureChildHeight(child, style, height, verticalMargins)
560
566
  val column = flowIndex % columns
561
567
  val row = flowIndex / columns
562
- while (rowHeights.size <= row) rowHeights += gridAutoRows ?: 0
568
+ while (rowHeights.size <= row) rowHeights += gridAutoRows?.resolve(null) ?: 0
563
569
  columnWidths[column] = max(columnWidths[column], childWidth + horizontalMargins)
564
570
  rowHeights[row] = max(rowHeights[row], childHeight + verticalMargins)
565
571
  flowIndex += 1
@@ -578,11 +584,28 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
578
584
  // previous fixed LayoutParams width after a reactive width is removed;
579
585
  // using that value here prevents intrinsic sizing from being restored.
580
586
  val explicit = style.width?.resolve(base) ?: flexBasis
581
- val target = explicit ?: base
582
- val spec = if (explicit != null) View.MeasureSpec.makeMeasureSpec(target, View.MeasureSpec.EXACTLY)
583
- else View.MeasureSpec.makeMeasureSpec(base, View.MeasureSpec.AT_MOST)
584
- child.measure(spec, View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED))
585
- return clamp(if (explicit != null) target else child.measuredWidth, style.minWidth?.resolve(base), style.maxWidth?.resolve(base))
587
+ if (explicit != null) {
588
+ child.measure(
589
+ View.MeasureSpec.makeMeasureSpec(explicit, View.MeasureSpec.EXACTLY),
590
+ View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED),
591
+ )
592
+ return clamp(explicit, style.minWidth?.resolve(base), style.maxWidth?.resolve(base))
593
+ }
594
+ // Measure text and nested flex containers at max-content first. An
595
+ // AT_MOST spec equal to the available room can make Android report the
596
+ // longest-word (min-content) width for a wrapping TextView, collapsing
597
+ // shrink-to-fit bubbles and pills before the parent can clamp them.
598
+ val intrinsicSpec = if (child is TextView && child !is EditText) 1_000_000 else base
599
+ child.measure(
600
+ View.MeasureSpec.makeMeasureSpec(intrinsicSpec, View.MeasureSpec.AT_MOST),
601
+ View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED),
602
+ )
603
+ val target = clamp(min(base, child.measuredWidth), style.minWidth?.resolve(base), style.maxWidth?.resolve(base))
604
+ child.measure(
605
+ View.MeasureSpec.makeMeasureSpec(target, View.MeasureSpec.AT_MOST),
606
+ View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED),
607
+ )
608
+ return target
586
609
  }
587
610
 
588
611
  private fun measureChildHeight(child: View, style: CraftNativeLayoutStyle, available: Int, margins: Int): Int {
@@ -629,6 +652,68 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
629
652
  else -> 0
630
653
  }
631
654
 
655
+ /** Keep the old-runtime shim on the same bounded-flex water-fill rule as the Zig bridge. */
656
+ private fun fallbackFlexWidths(line: Line, available: Int, gap: Int): IntArray {
657
+ val horizontal = orientation == HORIZONTAL
658
+ val hypothetical = line.entries.sumOf { entry ->
659
+ val lp = entry.view.layoutParams as? MarginLayoutParams
660
+ val margins = if (horizontal) (lp?.leftMargin ?: 0) + (lp?.rightMargin ?: 0)
661
+ else (lp?.topMargin ?: 0) + (lp?.bottomMargin ?: 0)
662
+ (if (horizontal) entry.width else entry.height) + margins
663
+ } + max(0, line.entries.size - 1) * gap
664
+ val free = available - hypothetical
665
+ val growTotal = line.entries.sumOf { it.style.flexGrow }
666
+ val shrinkTotal = line.entries.sumOf {
667
+ (it.style.flexShrink ?: defaultFlexShrink(it.view)) * (if (horizontal) it.width else it.height)
668
+ }
669
+ val growing = free > 0 && growTotal > 0
670
+ val shrinking = free < 0 && shrinkTotal > 0
671
+ val widths = IntArray(line.entries.size) { index -> if (horizontal) line.entries[index].width else line.entries[index].height }
672
+ if (!growing && !shrinking) return widths
673
+
674
+ val frozen = BooleanArray(widths.size)
675
+ repeat(widths.size + 1) {
676
+ var fixed = max(0, widths.size - 1) * gap.toDouble()
677
+ var activeBasis = 0.0
678
+ var factor = 0.0
679
+ for ((index, entry) in line.entries.withIndex()) {
680
+ val lp = entry.view.layoutParams as? MarginLayoutParams
681
+ val margins = if (horizontal) (lp?.leftMargin ?: 0) + (lp?.rightMargin ?: 0)
682
+ else (lp?.topMargin ?: 0) + (lp?.bottomMargin ?: 0)
683
+ fixed += margins
684
+ if (frozen[index]) fixed += widths[index]
685
+ else {
686
+ val basis = if (horizontal) entry.width else entry.height
687
+ activeBasis += basis
688
+ factor += if (growing) entry.style.flexGrow
689
+ else (entry.style.flexShrink ?: defaultFlexShrink(entry.view)) * basis
690
+ }
691
+ }
692
+ if (factor <= 0) return@repeat
693
+ val remaining = available - fixed - activeBasis
694
+ var froze = false
695
+ for ((index, entry) in line.entries.withIndex()) {
696
+ if (frozen[index]) continue
697
+ val basis = (if (horizontal) entry.width else entry.height).toDouble()
698
+ val share = if (growing) remaining * entry.style.flexGrow / factor
699
+ else -remaining * (entry.style.flexShrink ?: defaultFlexShrink(entry.view)) * basis / factor
700
+ val raw = if (growing) basis + share else basis - share
701
+ val lower = (if (horizontal) entry.style.minWidth else entry.style.minHeight)?.resolve(if (horizontal) width - paddingLeft - paddingRight else height - paddingTop - paddingBottom)
702
+ val upper = (if (horizontal) entry.style.maxWidth else entry.style.maxHeight)?.resolve(if (horizontal) width - paddingLeft - paddingRight else height - paddingTop - paddingBottom)
703
+ var bounded = raw
704
+ if (lower != null) bounded = max(bounded, lower.toDouble())
705
+ if (upper != null) bounded = min(bounded, upper.toDouble())
706
+ widths[index] = bounded.roundToInt().coerceAtLeast(0)
707
+ if (bounded != raw) {
708
+ frozen[index] = true
709
+ froze = true
710
+ }
711
+ }
712
+ if (!froze) return@repeat
713
+ }
714
+ return widths
715
+ }
716
+
632
717
  override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
633
718
  val widthMode = View.MeasureSpec.getMode(widthMeasureSpec)
634
719
  val width = View.MeasureSpec.getSize(widthMeasureSpec).takeUnless { widthMode == View.MeasureSpec.UNSPECIFIED } ?: 1_000_000
@@ -667,6 +752,8 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
667
752
  val contentWidth = width - paddingLeft - paddingRight
668
753
  val contentHeight = height - paddingTop - paddingBottom
669
754
  val flow = layout.rows.flatten()
755
+ // Older runtimes return null for the optional symbol; the local
756
+ // layout below remains the compatibility path in that case.
670
757
  val sharedFrames = CraftNative.grid(
671
758
  contentWidth.toFloat(), contentHeight.toFloat(), gridColumns, columnGap.toFloat(), rowGap.toFloat(),
672
759
  flow.map { it.width.toFloat() }.toFloatArray(), flow.map { it.height.toFloat() }.toFloatArray(),
@@ -684,6 +771,7 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
684
771
  val align = it.style.alignSelf ?: alignItems
685
772
  listOf(if (align == "stretch") 1f else 0f, if (align == "center") 1f else if (align == "flex-end") 2f else 0f)
686
773
  }.toFloatArray(),
774
+ gridAutoRows?.resolve(contentHeight)?.toFloat(),
687
775
  )
688
776
  if (sharedFrames != null) {
689
777
  for ((index, entry) in flow.withIndex()) {
@@ -770,7 +858,7 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
770
858
  var crossOffset = (if (orientation == HORIZONTAL) paddingTop else paddingLeft) + crossLeading
771
859
  for ((lineIndex, line) in lines.withIndex()) {
772
860
  val lineOffset = sharedCross?.first?.get(lineIndex)?.roundToInt()?.let { (if (orientation == HORIZONTAL) paddingTop else paddingLeft) + it } ?: crossOffset
773
- val rawFree = availableMain - line.main
861
+ val fallbackWidths = fallbackFlexWidths(line, availableMain, gap)
774
862
  val sharedPrimary = CraftNative.flexRow(
775
863
  availableMain.toFloat(),
776
864
  gap.toFloat(),
@@ -801,16 +889,8 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
801
889
  sharedPrimary[index].roundToInt() + margins
802
890
  } + max(0, line.entries.size - 1) * gap
803
891
  } else {
804
- val factors = line.entries.map { if (rawFree >= 0) it.style.flexGrow else it.style.flexShrink ?: defaultFlexShrink(it.view) }
805
- val totalFactor = factors.sum()
806
892
  line.entries.indices.sumOf { index ->
807
- val base = if (orientation == HORIZONTAL) line.entries[index].width else line.entries[index].height
808
- val primary = if (totalFactor > 0) max(0, (base + rawFree * factors[index] / totalFactor).toInt()) else base
809
- val constrained = if (orientation == HORIZONTAL) {
810
- clamp(primary, line.entries[index].style.minWidth?.resolve(contentWidth), line.entries[index].style.maxWidth?.resolve(contentWidth))
811
- } else {
812
- clamp(primary, line.entries[index].style.minHeight?.resolve(contentHeight), line.entries[index].style.maxHeight?.resolve(contentHeight))
813
- }
893
+ val constrained = fallbackWidths[index]
814
894
  val lp = line.entries[index].view.layoutParams as? MarginLayoutParams
815
895
  val margins = if (orientation == HORIZONTAL) (lp?.leftMargin ?: 0) + (lp?.rightMargin ?: 0)
816
896
  else (lp?.topMargin ?: 0) + (lp?.bottomMargin ?: 0)
@@ -851,17 +931,7 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
851
931
  ?: mainOffset + marginStart
852
932
  val x = if (orientation == HORIZONTAL) positionedMain else lineOffset + crossPosition
853
933
  val y = if (orientation == HORIZONTAL) lineOffset + crossPosition else positionedMain
854
- val distributedPrimary = sharedPrimary?.get(index)?.roundToInt() ?: run {
855
- val primary = if (orientation == HORIZONTAL) entry.width else entry.height
856
- val factor = if (rawFree >= 0) entry.style.flexGrow else entry.style.flexShrink ?: defaultFlexShrink(entry.view)
857
- val totalFactor = line.entries.sumOf { if (rawFree >= 0) it.style.flexGrow else it.style.flexShrink ?: defaultFlexShrink(it.view) }
858
- val distributed = if (totalFactor > 0) max(0, (primary + rawFree * factor / totalFactor).toInt()) else primary
859
- if (orientation == HORIZONTAL) {
860
- clamp(distributed, entry.style.minWidth?.resolve(contentWidth), entry.style.maxWidth?.resolve(contentWidth))
861
- } else {
862
- clamp(distributed, entry.style.minHeight?.resolve(contentHeight), entry.style.maxHeight?.resolve(contentHeight))
863
- }
864
- }
934
+ val distributedPrimary = sharedPrimary?.get(index)?.roundToInt() ?: fallbackWidths[index]
865
935
  val w = if (orientation == HORIZONTAL) distributedPrimary else crossSize
866
936
  val h = if (orientation == HORIZONTAL) crossSize else distributedPrimary
867
937
  entry.view.layout(x, y, x + w, y + h)
@@ -890,6 +960,67 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
890
960
  }
891
961
  }
892
962
 
963
+ /** An image-backed flex container with ordinary native children over the image. */
964
+ private class CraftNativeImageBackgroundView(context: Context) : FrameLayout(context) {
965
+ val image = ImageView(context)
966
+ val content = CraftNativeFlexLayout(context)
967
+
968
+ init {
969
+ clipChildren = true
970
+ image.scaleType = ImageView.ScaleType.CENTER_CROP
971
+ image.isClickable = false
972
+ addView(image, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
973
+ addView(content, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
974
+ }
975
+ }
976
+
977
+ /** A flex container that responds to the activity's IME inset. */
978
+ private class CraftNativeKeyboardAvoidingView(context: Context) : FrameLayout(context) {
979
+ val content = CraftNativeFlexLayout(context)
980
+ private var behavior = "padding"
981
+ private var keyboardVerticalOffset = 0
982
+ private var keyboardInset = 0
983
+ private var basePadding = intArrayOf(0, 0, 0, 0)
984
+
985
+ init {
986
+ addView(content, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
987
+ }
988
+
989
+ fun configure(props: JSONObject, style: JSONObject, density: Float) {
990
+ behavior = props.optString("behavior", "padding").takeIf { it in setOf("height", "position", "padding") } ?: "padding"
991
+ keyboardVerticalOffset = (props.optDouble("keyboardVerticalOffset", 0.0) * density).roundToInt()
992
+ content.configure(style, density)
993
+ basePadding = intArrayOf(content.paddingLeft, content.paddingTop, content.paddingRight, content.paddingBottom)
994
+ applyKeyboardInset()
995
+ }
996
+
997
+ fun setKeyboardInset(inset: Int) {
998
+ if (keyboardInset == inset) return
999
+ keyboardInset = max(0, inset)
1000
+ applyKeyboardInset()
1001
+ }
1002
+
1003
+ private fun effectiveInset(): Int = max(0, keyboardInset - keyboardVerticalOffset)
1004
+
1005
+ private fun applyKeyboardInset() {
1006
+ val inset = effectiveInset()
1007
+ translationY = if (behavior == "position") -inset.toFloat() else 0f
1008
+ val bottom = if (behavior == "padding") basePadding[3] + inset else basePadding[3]
1009
+ content.setPadding(basePadding[0], basePadding[1], basePadding[2], bottom)
1010
+ requestLayout()
1011
+ }
1012
+
1013
+ override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
1014
+ val inset = effectiveInset()
1015
+ val adjustedHeight = if (behavior == "height" && inset > 0 && MeasureSpec.getMode(heightMeasureSpec) != MeasureSpec.UNSPECIFIED) {
1016
+ MeasureSpec.makeMeasureSpec(max(0, MeasureSpec.getSize(heightMeasureSpec) - inset), MeasureSpec.getMode(heightMeasureSpec))
1017
+ } else {
1018
+ heightMeasureSpec
1019
+ }
1020
+ super.onMeasure(widthMeasureSpec, adjustedHeight)
1021
+ }
1022
+ }
1023
+
893
1024
  private class CraftNativeScrollView(context: Context) : SwipeRefreshLayout(context) {
894
1025
  val content = CraftNativeFlexLayout(context)
895
1026
  private val vertical = ScrollView(context)
@@ -939,6 +1070,21 @@ private class CraftNativeScrollView(context: Context) : SwipeRefreshLayout(conte
939
1070
  addView(scroller, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
940
1071
  }
941
1072
 
1073
+ /**
1074
+ * A targeted mutation can change a child's size without reconciling the
1075
+ * scroll content tree. Invalidate both the custom content layout and the
1076
+ * platform scroller so its measured content range is recomputed too.
1077
+ */
1078
+ fun invalidateContentLayout() {
1079
+ content.forceLayout()
1080
+ content.requestLayout()
1081
+ val scroller = if (horizontalMode == true) horizontal else vertical
1082
+ scroller.forceLayout()
1083
+ scroller.requestLayout()
1084
+ forceLayout()
1085
+ requestLayout()
1086
+ }
1087
+
942
1088
  fun setNativeNestedScrollingEnabled(value: Boolean) {
943
1089
  vertical.isNestedScrollingEnabled = value
944
1090
  horizontal.isNestedScrollingEnabled = value
@@ -1084,6 +1230,25 @@ private class CraftNativeScrollView(context: Context) : SwipeRefreshLayout(conte
1084
1230
 
1085
1231
  private class CraftNativeFlexSpacer(context: Context) : View(context)
1086
1232
 
1233
+ /**
1234
+ * A small, dependency-free native icon surface for Android.
1235
+ *
1236
+ * STX resolves Iconify names to the SF Symbol name that iOS draws. Android
1237
+ * does not ship SF Symbols, so the maintained host uses the corresponding
1238
+ * Unicode glyph for the common cross-platform symbols and a hollow circle for
1239
+ * an unknown symbol. Keeping this as a TextView gives the glyph the same
1240
+ * intrinsic measurement, tint, accessibility, and reconciliation behavior as
1241
+ * the other native leaf controls without adding an icon-font dependency.
1242
+ */
1243
+ private class CraftNativeIconView(context: Context) : TextView(context) {
1244
+ init {
1245
+ includeFontPadding = false
1246
+ gravity = Gravity.CENTER
1247
+ setPadding(0, 0, 0, 0)
1248
+ isSingleLine = true
1249
+ }
1250
+ }
1251
+
1087
1252
  private class CraftNativeModalView(context: Context) : FrameLayout(context) {
1088
1253
  val content = CraftNativeFlexLayout(context)
1089
1254
  private val blocker = View(context)
@@ -1214,6 +1379,176 @@ private class CraftNativeActivityIndicator(context: Context) :
1214
1379
  }
1215
1380
  }
1216
1381
 
1382
+ /** Android counterpart to iOS's UIDatePicker-backed native DatePicker. */
1383
+ private class CraftNativeDatePickerView(context: Context) : FrameLayout(context) {
1384
+ private enum class PickerMode { DATE, TIME, DATE_TIME, COUNTDOWN }
1385
+ private val maxCountdownSeconds = 23 * 3600 + 59 * 60 + 59
1386
+
1387
+ private var pickerMode: PickerMode? = null
1388
+ private var datePicker: DatePicker? = null
1389
+ private var timePicker: TimePicker? = null
1390
+ private var minuteInterval = 1
1391
+ private var suppressChanges = true
1392
+ var onValueChanged: (() -> Unit)? = null
1393
+
1394
+ init {
1395
+ isFocusable = true
1396
+ }
1397
+
1398
+ fun configure(props: JSONObject) {
1399
+ val requestedMode = when (props.optString("mode")) {
1400
+ "time" -> PickerMode.TIME
1401
+ "datetime", "dateAndTime", "datetime-local" -> PickerMode.DATE_TIME
1402
+ "countdown" -> PickerMode.COUNTDOWN
1403
+ else -> PickerMode.DATE
1404
+ }
1405
+ if (pickerMode != requestedMode) {
1406
+ pickerMode = requestedMode
1407
+ datePicker = null
1408
+ timePicker = null
1409
+ removeAllViews()
1410
+ val content = LinearLayout(context).apply {
1411
+ orientation = LinearLayout.VERTICAL
1412
+ gravity = Gravity.CENTER
1413
+ }
1414
+ if (requestedMode == PickerMode.DATE || requestedMode == PickerMode.DATE_TIME) {
1415
+ datePicker = DatePicker(context).also { picker ->
1416
+ val today = Calendar.getInstance()
1417
+ picker.init(today.get(Calendar.YEAR), today.get(Calendar.MONTH), today.get(Calendar.DAY_OF_MONTH)) { _, _, _, _ -> changed() }
1418
+ content.addView(picker, LinearLayout.LayoutParams(
1419
+ ViewGroup.LayoutParams.WRAP_CONTENT,
1420
+ ViewGroup.LayoutParams.WRAP_CONTENT,
1421
+ ))
1422
+ }
1423
+ }
1424
+ if (requestedMode != PickerMode.DATE) {
1425
+ timePicker = TimePicker(context).also { picker ->
1426
+ picker.setIs24HourView(true)
1427
+ picker.setOnTimeChangedListener { _, _, _ -> changed() }
1428
+ content.addView(picker, LinearLayout.LayoutParams(
1429
+ ViewGroup.LayoutParams.WRAP_CONTENT,
1430
+ ViewGroup.LayoutParams.WRAP_CONTENT,
1431
+ ))
1432
+ }
1433
+ }
1434
+ addView(content, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT))
1435
+ }
1436
+
1437
+ suppressChanges = true
1438
+ minuteInterval = props.optInt("minuteInterval", 1).takeIf { it in 1..30 && 60 % it == 0 } ?: 1
1439
+ pickerDate(props.opt("minimumDate"))?.let { datePicker?.minDate = it.timeInMillis }
1440
+ pickerDate(props.opt("maximumDate"))?.let { datePicker?.maxDate = it.timeInMillis }
1441
+ if (requestedMode == PickerMode.COUNTDOWN) {
1442
+ countdownSeconds(props.opt("value"))?.let { applyCountdown(it) }
1443
+ } else {
1444
+ pickerDate(props.opt("value"))?.let { applyDate(it) }
1445
+ }
1446
+ val enabled = !props.optBoolean("disabled", false)
1447
+ isEnabled = enabled
1448
+ datePicker?.isEnabled = enabled
1449
+ timePicker?.isEnabled = enabled
1450
+ for (index in 0 until childCount) getChildAt(index).isEnabled = enabled
1451
+ suppressChanges = false
1452
+ }
1453
+
1454
+ fun event(): JSONObject {
1455
+ if (pickerMode == PickerMode.COUNTDOWN) {
1456
+ val seconds = currentCountdownSeconds()
1457
+ return JSONObject()
1458
+ .put("value", formatCountdown(seconds))
1459
+ .put("iso", "PT${seconds}S")
1460
+ .put("timestamp", seconds * 1000L)
1461
+ }
1462
+ val current = currentDate()
1463
+ val valueFormat = when (pickerMode) {
1464
+ PickerMode.DATE -> "yyyy-MM-dd"
1465
+ PickerMode.TIME -> "HH:mm"
1466
+ else -> "yyyy-MM-dd'T'HH:mm"
1467
+ }
1468
+ val value = SimpleDateFormat(valueFormat, Locale.US).apply { isLenient = false }.format(current.time)
1469
+ val iso = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.US).format(current.time)
1470
+ return JSONObject().put("value", value).put("iso", iso).put("timestamp", current.timeInMillis)
1471
+ }
1472
+
1473
+ private fun changed() {
1474
+ if (suppressChanges) return
1475
+ val picker = timePicker ?: return
1476
+ val snapped = ((picker.minute + minuteInterval / 2) / minuteInterval * minuteInterval).coerceAtMost(59)
1477
+ if (snapped != picker.minute) {
1478
+ suppressChanges = true
1479
+ picker.minute = snapped
1480
+ suppressChanges = false
1481
+ }
1482
+ onValueChanged?.invoke()
1483
+ }
1484
+
1485
+ private fun applyDate(value: Calendar) {
1486
+ datePicker?.updateDate(value.get(Calendar.YEAR), value.get(Calendar.MONTH), value.get(Calendar.DAY_OF_MONTH))
1487
+ timePicker?.hour = value.get(Calendar.HOUR_OF_DAY)
1488
+ timePicker?.minute = value.get(Calendar.MINUTE)
1489
+ }
1490
+
1491
+ private fun applyCountdown(seconds: Int) {
1492
+ timePicker?.hour = (seconds / 3600).coerceIn(0, 23)
1493
+ timePicker?.minute = (seconds % 3600 / 60).coerceIn(0, 59)
1494
+ }
1495
+
1496
+ private fun currentCountdownSeconds(): Int = timePicker?.let { it.hour * 3600 + it.minute * 60 } ?: 0
1497
+
1498
+ private fun formatCountdown(seconds: Int): String = "%02d:%02d:%02d".format(
1499
+ Locale.US,
1500
+ seconds / 3600,
1501
+ seconds % 3600 / 60,
1502
+ seconds % 60,
1503
+ )
1504
+
1505
+ private fun currentDate(): Calendar = Calendar.getInstance().also { current ->
1506
+ datePicker?.let {
1507
+ current.set(Calendar.YEAR, it.year)
1508
+ current.set(Calendar.MONTH, it.month)
1509
+ current.set(Calendar.DAY_OF_MONTH, it.dayOfMonth)
1510
+ }
1511
+ timePicker?.let {
1512
+ current.set(Calendar.HOUR_OF_DAY, it.hour)
1513
+ current.set(Calendar.MINUTE, it.minute)
1514
+ current.set(Calendar.SECOND, 0)
1515
+ current.set(Calendar.MILLISECOND, 0)
1516
+ }
1517
+ }
1518
+
1519
+ private fun pickerDate(value: Any?): Calendar? {
1520
+ if (value is Number && value !is Boolean) return Calendar.getInstance().apply { timeInMillis = value.toLong() }
1521
+ val text = value?.toString()?.trim().orEmpty()
1522
+ if (text.isBlank() || text == JSONObject.NULL.toString()) return null
1523
+ val formats = listOf(
1524
+ "HH:mm", "HH:mm:ss", "yyyy-MM-dd", "yyyy-MM-dd'T'HH:mm",
1525
+ "yyyy-MM-dd'T'HH:mm:ss", "yyyy-MM-dd'T'HH:mm:ss.SSSXXX", "yyyy-MM-dd'T'HH:mm:ssX",
1526
+ )
1527
+ formats.firstNotNullOfOrNull { format ->
1528
+ runCatching {
1529
+ SimpleDateFormat(format, Locale.US).apply { isLenient = false }.parse(text)
1530
+ }.getOrNull()
1531
+ }?.let { parsed ->
1532
+ return Calendar.getInstance().apply { time = parsed }
1533
+ }
1534
+ return null
1535
+ }
1536
+
1537
+ private fun countdownSeconds(value: Any?): Int? {
1538
+ if (value is Number && value !is Boolean) return value.toDouble().coerceIn(0.0, maxCountdownSeconds.toDouble()).roundToInt()
1539
+ val text = value?.toString()?.trim().orEmpty()
1540
+ if (text.isBlank() || text == JSONObject.NULL.toString()) return null
1541
+ text.toDoubleOrNull()?.takeIf { it.isFinite() }?.let { return it.coerceIn(0.0, maxCountdownSeconds.toDouble()).roundToInt() }
1542
+ val parts = text.split(':').mapNotNull { it.toDoubleOrNull() }
1543
+ if (parts.size != 2 && parts.size != 3) return null
1544
+ val hours = parts[0]
1545
+ val minutes = parts[1]
1546
+ val seconds = if (parts.size == 3) parts[2] else 0.0
1547
+ if (hours < 0 || minutes !in 0.0..<60.0 || seconds !in 0.0..<60.0) return null
1548
+ return (hours * 3600 + minutes * 60 + seconds).coerceIn(0.0, maxCountdownSeconds.toDouble()).roundToInt()
1549
+ }
1550
+ }
1551
+
1217
1552
  private class CraftNativeVideoView(context: Context) : VideoView(context) {
1218
1553
  private var sourceUri = ""
1219
1554
  private var shouldLoop = false
@@ -1437,6 +1772,11 @@ class MainActivity : AppCompatActivity() {
1437
1772
  val inputIdentities = mutableMapOf<EditText, String>()
1438
1773
  val suppressedTextChanges = mutableSetOf<EditText>()
1439
1774
  val controls = mutableMapOf<String, View>()
1775
+ // Protocol ids identify mutation targets; authored keys identify the
1776
+ // retained view. Keep both mappings so a targeted update can recover
1777
+ // the original tree path without manufacturing a detached view.
1778
+ val nodeViews = mutableMapOf<String, View>()
1779
+ val nodePaths = mutableMapOf<String, String>()
1440
1780
  val handlers = mutableMapOf<View, String>()
1441
1781
  val pressInHandlers = mutableMapOf<View, String>()
1442
1782
  val pressOutHandlers = mutableMapOf<View, String>()
@@ -1520,6 +1860,7 @@ class MainActivity : AppCompatActivity() {
1520
1860
  private val inputHintDefaults = mutableMapOf<EditText, ColorStateList>()
1521
1861
  private val inputHighlightDefaults = mutableMapOf<EditText, Int>()
1522
1862
  private val defaultBackgrounds = mutableMapOf<View, Drawable?>()
1863
+ private var keyboardInset = 0
1523
1864
 
1524
1865
  override fun onCreate(savedInstanceState: Bundle?) {
1525
1866
  applyConfiguredAppearance()
@@ -1531,7 +1872,10 @@ class MainActivity : AppCompatActivity() {
1531
1872
  root = findViewById(R.id.native_root)
1532
1873
  ViewCompat.setOnApplyWindowInsetsListener(root) { view, insets ->
1533
1874
  val safe = insets.getInsets(WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout())
1875
+ val ime = insets.getInsets(WindowInsetsCompat.Type.ime())
1876
+ keyboardInset = max(0, ime.bottom - safe.bottom)
1534
1877
  view.setPadding(safe.left, safe.top, safe.right, safe.bottom)
1878
+ updateKeyboardAvoidingViews()
1535
1879
  view.requestLayout()
1536
1880
  view.post { screens.lastOrNull()?.takeIf { it.active }?.let(::emitLayoutEvents) }
1537
1881
  insets
@@ -1926,9 +2270,10 @@ class MainActivity : AppCompatActivity() {
1926
2270
  val target = screen.flatListOwners[id] ?: id
1927
2271
  if (!applied.add(target)) continue
1928
2272
  val document = screen.mutations.node(target) ?: return false
1929
- if (!screen.controls.containsKey("id:$target")) return false
1930
- makeView(screen, document, target, screen.controls)
1931
- val updated = screen.controls["id:$target"] ?: return false
2273
+ val path = screen.nodePaths[target] ?: return false
2274
+ if (screen.nodeViews[target] == null) return false
2275
+ makeView(screen, document, path, screen.controls)
2276
+ val updated = screen.nodeViews[target] ?: return false
1932
2277
  screen.lastLayoutBounds.remove(updated)
1933
2278
  if (!updateParentLayoutStyle(updated, document.optJSONObject("style") ?: JSONObject())) return false
1934
2279
  }
@@ -1966,6 +2311,12 @@ class MainActivity : AppCompatActivity() {
1966
2311
  private fun updateParentLayoutStyle(view: View, style: JSONObject): Boolean {
1967
2312
  var parent = view.parent
1968
2313
  var found = false
2314
+ // A targeted style update can arrive while Android considers the
2315
+ // existing traversal settled. Invalidate the node itself as well as
2316
+ // every ancestor, including the platform ScrollView between the
2317
+ // native content layout and CraftNativeScrollView.
2318
+ view.forceLayout()
2319
+ view.requestLayout()
1969
2320
  while (parent != null) {
1970
2321
  when (parent) {
1971
2322
  is CraftNativeFlexLayout -> {
@@ -1975,10 +2326,13 @@ class MainActivity : AppCompatActivity() {
1975
2326
  found = true
1976
2327
  }
1977
2328
  is CraftNativeScrollView -> {
1978
- parent.forceLayout()
1979
- parent.requestLayout()
2329
+ parent.invalidateContentLayout()
1980
2330
  }
1981
2331
  }
2332
+ (parent as? View)?.let {
2333
+ it.forceLayout()
2334
+ it.requestLayout()
2335
+ }
1982
2336
  parent = parent.parent
1983
2337
  }
1984
2338
  return found
@@ -1987,6 +2341,8 @@ class MainActivity : AppCompatActivity() {
1987
2341
  private fun renderCommitted(screen: Screen, document: JSONObject) {
1988
2342
  // Keep live controls (especially the focused EditText) across renders.
1989
2343
  val next = mutableMapOf<String, View>()
2344
+ screen.nodeViews.clear()
2345
+ screen.nodePaths.clear()
1990
2346
  val tree = makeView(screen, document, "root", next)
1991
2347
  if (screen.view.getChildAt(0) !== tree) {
1992
2348
  (tree.parent as? ViewGroup)?.removeView(tree)
@@ -1998,12 +2354,18 @@ class MainActivity : AppCompatActivity() {
1998
2354
  screen.controls.putAll(next)
1999
2355
  screen.handlers.keys.retainAll(retainedViews(screen))
2000
2356
  screen.view.setChildStyle(tree, document.optJSONObject("style") ?: JSONObject(), resources.displayMetrics.density)
2357
+ // A full render can change a view's custom layout style without
2358
+ // changing the root bounds. Ensure native @layout observers still see
2359
+ // the settled child frame after the next traversal.
2360
+ scheduleLayoutEvents(screen)
2001
2361
  }
2002
2362
 
2003
2363
  private fun clearRenderedTree(screen: Screen) {
2004
2364
  releaseViewState(screen, retainedViews(screen).toList())
2005
2365
  screen.view.removeAllViews()
2006
2366
  screen.controls.clear()
2367
+ screen.nodeViews.clear()
2368
+ screen.nodePaths.clear()
2007
2369
  screen.handlers.clear()
2008
2370
  screen.flatListRows.clear()
2009
2371
  screen.flatListOwners.clear()
@@ -2017,16 +2379,15 @@ class MainActivity : AppCompatActivity() {
2017
2379
  next: MutableMap<String, View>,
2018
2380
  previousControls: Map<String, View> = screen.controls,
2019
2381
  ): View {
2020
- val type = node.optString("type", "View")
2382
+ val type = canonicalType(node.optString("type", "View"))
2021
2383
  val props = node.optJSONObject("props") ?: JSONObject()
2022
2384
  val style = node.optJSONObject("style") ?: JSONObject()
2023
2385
  val events = node.optJSONObject("events") ?: JSONObject()
2024
2386
  val children = node.optJSONArray("children") ?: JSONArray()
2025
2387
  val protocolId = node.optString("id").takeIf { it.isNotBlank() }
2026
- val explicitKey = listOf(node.optString("key"), props.optString("key"), props.optString("testID"))
2027
- .firstOrNull { it.isNotBlank() }
2028
- val identity = protocolId?.let { "id:$it" }
2029
- ?: explicitKey?.let { "${path.substringBeforeLast('.', "")}/$it" }
2388
+ val explicitKey = authoredKey(node, props)
2389
+ val identity = explicitKey?.let { "${path.substringBeforeLast('.', "")}/$it" }
2390
+ ?: protocolId?.let { "id:$it" }
2030
2391
  ?: path
2031
2392
  val previous = previousControls[identity]
2032
2393
  val reusableInput = previous as? EditText
@@ -2071,6 +2432,32 @@ class MainActivity : AppCompatActivity() {
2071
2432
  send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject()))
2072
2433
  }
2073
2434
  }
2435
+ "Checkbox" -> (previous as? CheckBox ?: CheckBox(this)).apply {
2436
+ text = props.optString("label").ifBlank { childText(children) }
2437
+ configureText(this, text.toString(), style)
2438
+ val requested = props.opt("checked") ?: props.opt("value")
2439
+ val checked = when (requested) {
2440
+ is Boolean -> requested
2441
+ is Number -> requested.toDouble() != 0.0
2442
+ is String -> requested.equals("true", ignoreCase = true) || requested == "1"
2443
+ else -> false
2444
+ }
2445
+ setOnCheckedChangeListener(null)
2446
+ // Keep the visual checked state separate from indeterminate:
2447
+ // an indeterminate control must announce "mixed" and its next
2448
+ // user toggle should transition to checked, not unchecked.
2449
+ isChecked = checked
2450
+ isActivated = props.optBoolean("indeterminate", false)
2451
+ isEnabled = !props.optBoolean("disabled", false)
2452
+ props.optString("color").takeIf { it.isNotBlank() }?.let { buttonTintList = ColorStateList.valueOf(color(it, defaultTextColor)) }
2453
+ val handler = events.optString("onValueChange").ifBlank { events.optString("onChange") }
2454
+ if (handler.isNotBlank()) screen.handlers[this] = handler else screen.handlers.remove(this)
2455
+ setOnCheckedChangeListener { _, value ->
2456
+ screen.handlers[this]?.let { current ->
2457
+ send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject().put("value", value)))
2458
+ }
2459
+ }
2460
+ }
2074
2461
  "TextInput" -> (reusableInput?.takeIf { it.isSingleLine == !props.optBoolean("multiline", false) }
2075
2462
  ?: EditText(this)).apply {
2076
2463
  val defaultHintColors = inputHintDefaults.getOrPut(this) {
@@ -2213,6 +2600,16 @@ class MainActivity : AppCompatActivity() {
2213
2600
  setSelection(selected, false)
2214
2601
  post { suppressInitialSelection = false }
2215
2602
  }
2603
+ "DatePicker" -> (previous as? CraftNativeDatePickerView ?: CraftNativeDatePickerView(this)).apply {
2604
+ configure(props)
2605
+ val handler = events.optString("onValueChange").ifBlank { events.optString("onChange") }
2606
+ if (handler.isNotBlank()) screen.handlers[this] = handler else screen.handlers.remove(this)
2607
+ onValueChanged = {
2608
+ screen.handlers[this]?.let { current ->
2609
+ send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", event()))
2610
+ }
2611
+ }
2612
+ }
2216
2613
  "Modal" -> (previous as? CraftNativeModalView ?: CraftNativeModalView(this)).apply {
2217
2614
  transparent = props.optBoolean("transparent", false)
2218
2615
  content.configure(style, resources.displayMetrics.density)
@@ -2353,6 +2750,9 @@ class MainActivity : AppCompatActivity() {
2353
2750
  indeterminateTintList = props.optString("color").takeIf { it.isNotBlank() }
2354
2751
  ?.let { ColorStateList.valueOf(color(it, Color.WHITE)) }
2355
2752
  }
2753
+ "Icon" -> (previous as? CraftNativeIconView ?: CraftNativeIconView(this)).apply {
2754
+ configureIcon(this, props, style)
2755
+ }
2356
2756
  "Image" -> (previous as? ImageView ?: ImageView(this)).apply {
2357
2757
  scaleType = when (style.optString("resizeMode").ifBlank { props.optString("resizeMode") }) {
2358
2758
  "cover" -> ImageView.ScaleType.CENTER_CROP
@@ -2371,6 +2771,25 @@ class MainActivity : AppCompatActivity() {
2371
2771
  if (onError.isNotBlank()) screen.imageErrorHandlers[this] = onError else screen.imageErrorHandlers.remove(this)
2372
2772
  updateImage(screen, this, props.opt("source"))
2373
2773
  }
2774
+ "ImageBackground" -> (previous as? CraftNativeImageBackgroundView ?: CraftNativeImageBackgroundView(this)).apply {
2775
+ image.scaleType = when (style.optString("resizeMode").ifBlank { props.optString("resizeMode") }) {
2776
+ "cover" -> ImageView.ScaleType.CENTER_CROP
2777
+ "stretch" -> ImageView.ScaleType.FIT_XY
2778
+ "center" -> ImageView.ScaleType.CENTER
2779
+ else -> ImageView.ScaleType.CENTER_INSIDE
2780
+ }
2781
+ val onLoadStart = events.optString("onLoadStart")
2782
+ if (onLoadStart.isNotBlank()) screen.imageLoadStartHandlers[image] = onLoadStart else screen.imageLoadStartHandlers.remove(image)
2783
+ val onLoad = events.optString("onLoad")
2784
+ if (onLoad.isNotBlank()) screen.imageLoadHandlers[image] = onLoad else screen.imageLoadHandlers.remove(image)
2785
+ val onLoadEnd = events.optString("onLoadEnd")
2786
+ if (onLoadEnd.isNotBlank()) screen.imageLoadEndHandlers[image] = onLoadEnd else screen.imageLoadEndHandlers.remove(image)
2787
+ val onError = events.optString("onError")
2788
+ if (onError.isNotBlank()) screen.imageErrorHandlers[image] = onError else screen.imageErrorHandlers.remove(image)
2789
+ updateImage(screen, image, props.opt("source"))
2790
+ configureContainer(content, style)
2791
+ reconcileChildren(screen, content, children, path, next, style, previousControls)
2792
+ }
2374
2793
  "Video" -> (previous as? CraftNativeVideoView ?: CraftNativeVideoView(this)).apply {
2375
2794
  configure(
2376
2795
  source = props.opt("source"),
@@ -2477,6 +2896,11 @@ class MainActivity : AppCompatActivity() {
2477
2896
  configureContainer(this, style)
2478
2897
  reconcileChildren(screen, this, children, path, next, style, previousControls)
2479
2898
  }
2899
+ "KeyboardAvoidingView" -> (previous as? CraftNativeKeyboardAvoidingView ?: CraftNativeKeyboardAvoidingView(this)).apply {
2900
+ configure(props, style, resources.displayMetrics.density)
2901
+ setKeyboardInset(keyboardInset)
2902
+ reconcileChildren(screen, content, children, path, next, style, previousControls)
2903
+ }
2480
2904
  else -> (previous as? CraftNativeFlexLayout ?: CraftNativeFlexLayout(this)).apply {
2481
2905
  configureContainer(this, style)
2482
2906
  reconcileChildren(screen, this, children, path, next, style, previousControls)
@@ -2484,12 +2908,14 @@ class MainActivity : AppCompatActivity() {
2484
2908
  }
2485
2909
  if (control is CraftNativeFlexLayout) {
2486
2910
  control.onLayoutChanged = { if (screen.active) emitLayoutEvents(screen) }
2911
+ } else if (control is CraftNativeKeyboardAvoidingView) {
2912
+ control.content.onLayoutChanged = { if (screen.active) emitLayoutEvents(screen) }
2487
2913
  }
2488
2914
  val pressInHandler = events.optString("onPressIn").takeIf { it.isNotBlank() }
2489
2915
  val pressOutHandler = events.optString("onPressOut").takeIf { it.isNotBlank() }
2490
2916
  if (pressInHandler != null) screen.pressInHandlers[control] = pressInHandler else screen.pressInHandlers.remove(control)
2491
2917
  if (pressOutHandler != null) screen.pressOutHandlers[control] = pressOutHandler else screen.pressOutHandlers.remove(control)
2492
- if (type != "Button" && type != "Link" && type != "TextInput" && type != "Picker" && type != "Modal" && type != "Switch" && type != "Slider" && type != "ActivityIndicator") {
2918
+ if (type != "Button" && type != "Link" && type != "TextInput" && type != "Picker" && type != "DatePicker" && type != "Modal" && type != "Switch" && type != "Slider" && type != "ActivityIndicator" && type != "KeyboardAvoidingView") {
2493
2919
  val handler = events.optString("onPress").ifBlank { events.optString("onClick") }
2494
2920
  val disabled = props.optBoolean("disabled", false)
2495
2921
  val hasPressLifecycle = pressInHandler != null || pressOutHandler != null
@@ -2571,6 +2997,15 @@ class MainActivity : AppCompatActivity() {
2571
2997
  if (!current.contains(message)) {
2572
2998
  control.contentDescription = listOf(current, message).filter { it.isNotBlank() }.joinToString(", ")
2573
2999
  }
3000
+ } else if (control is CraftNativeImageBackgroundView) imageErrors[control.image]?.let { message ->
3001
+ val current = control.contentDescription?.toString().orEmpty()
3002
+ if (!current.contains(message)) {
3003
+ control.contentDescription = listOf(current, message).filter { it.isNotBlank() }.joinToString(", ")
3004
+ }
3005
+ }
3006
+ protocolId?.let {
3007
+ screen.nodeViews[it] = control
3008
+ screen.nodePaths[it] = path
2574
3009
  }
2575
3010
  next[identity] = control
2576
3011
  applyViewStyle(control, style)
@@ -2609,6 +3044,47 @@ class MainActivity : AppCompatActivity() {
2609
3044
  }
2610
3045
  }
2611
3046
 
3047
+ private fun canonicalType(raw: String): String = when (raw.lowercase()) {
3048
+ "view" -> "View"
3049
+ "safeareaview" -> "SafeAreaView"
3050
+ "text" -> "Text"
3051
+ "button" -> "Button"
3052
+ "link" -> "Link"
3053
+ "checkbox" -> "Checkbox"
3054
+ "textinput", "input" -> "TextInput"
3055
+ "picker", "select" -> "Picker"
3056
+ "datepicker", "date-picker", "date" -> "DatePicker"
3057
+ "modal" -> "Modal"
3058
+ "switch" -> "Switch"
3059
+ "slider" -> "Slider"
3060
+ "activityindicator" -> "ActivityIndicator"
3061
+ "image" -> "Image"
3062
+ "imagebackground" -> "ImageBackground"
3063
+ "keyboardavoidingview" -> "KeyboardAvoidingView"
3064
+ "video" -> "Video"
3065
+ "icon" -> "Icon"
3066
+ "scrollview" -> "ScrollView"
3067
+ "flatlist" -> "FlatList"
3068
+ "svg" -> "Svg"
3069
+ "touchableopacity" -> "TouchableOpacity"
3070
+ "touchablehighlight" -> "TouchableHighlight"
3071
+ "pressable" -> "Pressable"
3072
+ else -> raw
3073
+ }
3074
+
3075
+ private fun authoredKey(node: JSONObject, props: JSONObject): String? {
3076
+ val direct = listOf(node.optString("key"), props.optString("key"), props.optString("testID"))
3077
+ .firstOrNull { it.isNotBlank() }
3078
+ if (direct != null) return direct
3079
+ val children = node.optJSONArray("children") ?: return null
3080
+ for (index in 0 until children.length()) {
3081
+ val child = children.optJSONObject(index) ?: continue
3082
+ val childProps = child.optJSONObject("props") ?: JSONObject()
3083
+ authoredKey(child, childProps)?.let { return it }
3084
+ }
3085
+ return node.optString("id").takeIf { it.isNotBlank() }
3086
+ }
3087
+
2612
3088
  private fun configureContainer(layout: CraftNativeFlexLayout, style: JSONObject) {
2613
3089
  layout.configure(style, resources.displayMetrics.density)
2614
3090
  }
@@ -2793,6 +3269,59 @@ class MainActivity : AppCompatActivity() {
2793
3269
  configureTextStyle(view, style)
2794
3270
  }
2795
3271
 
3272
+ private fun configureIcon(view: CraftNativeIconView, props: JSONObject, style: JSONObject) {
3273
+ val symbol = props.optString("symbol").ifBlank { props.optString("name") }
3274
+ view.text = androidIconGlyph(symbol)
3275
+ val size = style.optDouble("fontSize", 20.0).toFloat().coerceAtLeast(1f)
3276
+ view.setTextSize(TypedValue.COMPLEX_UNIT_SP, size)
3277
+ val foreground = style.optString("color").ifBlank { props.optString("color") }
3278
+ view.setTextColor(color(foreground, defaultTextColor))
3279
+ val weight = when (style.optString("fontWeight")) {
3280
+ "bold", "700", "800", "900" -> Typeface.BOLD
3281
+ else -> Typeface.NORMAL
3282
+ }
3283
+ view.typeface = Typeface.create("sans-serif", weight)
3284
+ if (!style.has("width") && !style.has("height")) {
3285
+ val box = dp(size.toDouble())
3286
+ view.minimumWidth = box
3287
+ view.minimumHeight = box
3288
+ }
3289
+ }
3290
+
3291
+ private fun androidIconGlyph(symbol: String): String = when (symbol) {
3292
+ "arrow.left" -> "←"
3293
+ "arrow.right" -> "→"
3294
+ "arrow.up" -> "↑"
3295
+ "arrow.down" -> "↓"
3296
+ "chevron.left" -> "‹"
3297
+ "chevron.right" -> "›"
3298
+ "chevron.up" -> "⌃"
3299
+ "chevron.down" -> "⌄"
3300
+ "plus" -> "+"
3301
+ "minus" -> "−"
3302
+ "xmark" -> "×"
3303
+ "checkmark" -> "✓"
3304
+ "house", "house.fill" -> "⌂"
3305
+ "magnifyingglass" -> "⌕"
3306
+ "gear", "gearshape" -> "⚙"
3307
+ "heart" -> "♡"
3308
+ "heart.fill" -> "♥"
3309
+ "star" -> "☆"
3310
+ "star.fill" -> "★"
3311
+ "sun.max" -> "☼"
3312
+ "moon" -> "☾"
3313
+ "info.circle", "info.circle.fill" -> "ⓘ"
3314
+ "questionmark.circle", "questionmark.circle.fill" -> "?"
3315
+ "exclamationmark.triangle", "exclamationmark.triangle.fill" -> "⚠"
3316
+ "play", "play.fill" -> "▶"
3317
+ "pause", "pause.fill" -> "Ⅱ"
3318
+ "stop", "stop.fill" -> "■"
3319
+ "camera" -> "▣"
3320
+ "circle" -> "○"
3321
+ "circle.fill" -> "●"
3322
+ else -> "○"
3323
+ }
3324
+
2796
3325
  private fun configureTextStyle(view: TextView, style: JSONObject) {
2797
3326
  view.setTextColor(color(style.optString("color"), defaultTextColor))
2798
3327
  view.setTextSize(TypedValue.COMPLEX_UNIT_SP, style.optDouble("fontSize", 16.0).toFloat())
@@ -2939,7 +3468,7 @@ class MainActivity : AppCompatActivity() {
2939
3468
  private fun applyAccessibility(view: View, props: JSONObject, type: String, fallbackLabel: String) {
2940
3469
  view.tag = fallbackLabel
2941
3470
  val label = props.optString("accessibilityLabel").takeIf { it.isNotBlank() }
2942
- ?: props.optString("alt").takeIf { type == "Image" && it.isNotBlank() }
3471
+ ?: props.optString("alt").takeIf { (type == "Image" || type == "ImageBackground") && it.isNotBlank() }
2943
3472
  val value = props.opt("accessibilityValue")?.takeUnless { it == JSONObject.NULL }?.toString()
2944
3473
  view.contentDescription = when {
2945
3474
  label != null && value != null -> "$label, $value"
@@ -2951,11 +3480,15 @@ class MainActivity : AppCompatActivity() {
2951
3480
  val role = props.optString("accessibilityRole").ifBlank { type.lowercase() }
2952
3481
  val state = props.optJSONObject("accessibilityState")
2953
3482
  val disabled = props.optBoolean("disabled", false) || state?.optBoolean("disabled", false) == true
2954
- if (type !in setOf("Button", "Link", "TextInput", "Picker", "Modal", "Switch", "Slider", "ActivityIndicator")) {
3483
+ if (type !in setOf("Button", "Link", "Checkbox", "TextInput", "Picker", "DatePicker", "Modal", "Switch", "Slider", "ActivityIndicator")) {
2955
3484
  view.isEnabled = !disabled
2956
3485
  } else if (disabled) {
2957
3486
  view.isEnabled = false
2958
3487
  }
3488
+ ViewCompat.setStateDescription(
3489
+ view,
3490
+ if (type == "Checkbox" && props.optBoolean("indeterminate", false)) "mixed" else null,
3491
+ )
2959
3492
  ViewCompat.setAccessibilityDelegate(view, object : AccessibilityDelegateCompat() {
2960
3493
  override fun onInitializeAccessibilityNodeInfo(host: View, info: AccessibilityNodeInfoCompat) {
2961
3494
  super.onInitializeAccessibilityNodeInfo(host, info)
@@ -3156,6 +3689,14 @@ class MainActivity : AppCompatActivity() {
3156
3689
  emitLayoutEvents(screen, screen.view)
3157
3690
  }
3158
3691
 
3692
+ private fun updateKeyboardAvoidingViews() {
3693
+ fun visit(view: View) {
3694
+ (view as? CraftNativeKeyboardAvoidingView)?.setKeyboardInset(keyboardInset)
3695
+ if (view is ViewGroup) for (index in 0 until view.childCount) visit(view.getChildAt(index))
3696
+ }
3697
+ if (::root.isInitialized) visit(root)
3698
+ }
3699
+
3159
3700
  private fun emitLayoutEvents(screen: Screen, view: View) {
3160
3701
  emitLayoutEvent(screen, view, view.left, view.top, view.right, view.bottom)
3161
3702
  if (view is ViewGroup) {
@@ -3213,8 +3754,16 @@ class MainActivity : AppCompatActivity() {
3213
3754
  }
3214
3755
  }
3215
3756
 
3757
+ private fun imageViews(views: Collection<View>): Set<ImageView> = buildSet {
3758
+ fun collect(view: View) {
3759
+ if (view is ImageView) add(view)
3760
+ if (view is ViewGroup) for (index in 0 until view.childCount) collect(view.getChildAt(index))
3761
+ }
3762
+ views.forEach(::collect)
3763
+ }
3764
+
3216
3765
  private fun releaseImages(views: Collection<View>) {
3217
- views.filterIsInstance<ImageView>().forEach { image ->
3766
+ imageViews(views).forEach { image ->
3218
3767
  imageJobs.remove(image)?.cancel(true)
3219
3768
  imageSources.remove(image)
3220
3769
  imageErrors.remove(image)
@@ -3244,6 +3793,7 @@ class MainActivity : AppCompatActivity() {
3244
3793
  preservingInputDrafts: Boolean = false,
3245
3794
  ) {
3246
3795
  val released = views.toMutableSet()
3796
+ val releasedImages = imageViews(released)
3247
3797
  val refreshableLists = released.filterIsInstance<CraftNativeRefreshableFlatList>()
3248
3798
  val nestedLists = refreshableLists.map { it.list }.toSet()
3249
3799
  released.addAll(nestedLists)
@@ -3256,6 +3806,7 @@ class MainActivity : AppCompatActivity() {
3256
3806
  released.filterIsInstance<CraftNativeModalView>().forEach { it.resetForTeardown() }
3257
3807
  released.filterIsInstance<CraftNativeVideoView>().forEach { it.stop() }
3258
3808
  released.filterIsInstance<CraftNativeFlexLayout>().forEach { it.onLayoutChanged = null }
3809
+ released.filterIsInstance<CraftNativeKeyboardAvoidingView>().forEach { it.content.onLayoutChanged = null }
3259
3810
  released.filterIsInstance<CraftNativeScrollView>().forEach { it.setRefreshHandler(null, false) }
3260
3811
  releaseImages(released)
3261
3812
  released.filterIsInstance<EditText>().forEach {
@@ -3287,10 +3838,10 @@ class MainActivity : AppCompatActivity() {
3287
3838
  screen.blurHandlers.keys.removeAll(released)
3288
3839
  screen.endEditingHandlers.keys.removeAll(released)
3289
3840
  screen.submitHandlers.keys.removeAll(released)
3290
- screen.imageLoadStartHandlers.keys.removeAll(released)
3291
- screen.imageLoadHandlers.keys.removeAll(released)
3292
- screen.imageLoadEndHandlers.keys.removeAll(released)
3293
- screen.imageErrorHandlers.keys.removeAll(released)
3841
+ screen.imageLoadStartHandlers.keys.removeAll(releasedImages)
3842
+ screen.imageLoadHandlers.keys.removeAll(releasedImages)
3843
+ screen.imageLoadEndHandlers.keys.removeAll(releasedImages)
3844
+ screen.imageErrorHandlers.keys.removeAll(releasedImages)
3294
3845
  screen.pressActiveOpacities.keys.removeAll(released)
3295
3846
  screen.pressBaseOpacities.keys.removeAll(released)
3296
3847
  screen.modalVisibility.keys.removeAll(released)
@@ -3304,6 +3855,8 @@ class MainActivity : AppCompatActivity() {
3304
3855
  screen.pendingCapabilities.clear()
3305
3856
  releaseViewState(screen, retainedViews(screen).toList())
3306
3857
  screen.controls.clear()
3858
+ screen.nodeViews.clear()
3859
+ screen.nodePaths.clear()
3307
3860
  screen.handlers.clear()
3308
3861
  screen.pressInHandlers.clear()
3309
3862
  screen.pressOutHandlers.clear()
@@ -3341,6 +3894,11 @@ class MainActivity : AppCompatActivity() {
3341
3894
  back()
3342
3895
  }
3343
3896
 
3897
+ override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
3898
+ if (::capabilities.isInitialized && capabilities.onActivityResult(requestCode, resultCode, data)) return
3899
+ super.onActivityResult(requestCode, resultCode, data)
3900
+ }
3901
+
3344
3902
  private fun showError(message: String) {
3345
3903
  root.removeAllViews()
3346
3904
  root.addView(TextView(this).apply {