@craft-native/android 0.0.206 → 0.0.209

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craft-native/android",
3
- "version": "0.0.206",
3
+ "version": "0.0.209",
4
4
  "homepage": "https://github.com/craft-native/craft/tree/main/packages/android#readme",
5
5
  "repository": {
6
6
  "type": "git",
@@ -578,6 +578,11 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
578
578
  private val largeTitleView = TextView(context)
579
579
  private val buttons = LinearLayout(context)
580
580
  private val tabBar = LinearLayout(context)
581
+ private val tabRow = LinearLayout(context)
582
+ private val accessory = FrameLayout(context)
583
+ private val accessoryIcon = ImageView(context)
584
+ private val accessoryBadge = TextView(context)
585
+ private var accessorySpec: JSONObject? = null
581
586
  private val tabViews = mutableListOf<Pair<TextView, TextView>>()
582
587
  private val tabIconText = mutableMapOf<String, String>()
583
588
  private val tabImageURLs = mutableMapOf<String, String>()
@@ -596,6 +601,7 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
596
601
  var onBack: (() -> Unit)? = null
597
602
  var onButton: ((String) -> Unit)? = null
598
603
  var onTab: ((Int) -> Unit)? = null
604
+ var onAccessory: ((String) -> Unit)? = null
599
605
  var onTabSwipeStart: ((Float, Float, Float) -> Boolean)? = null
600
606
  var onTabSwipeMove: ((Float) -> Unit)? = null
601
607
  var onTabSwipeEnd: ((Float, Boolean) -> Unit)? = null
@@ -660,8 +666,22 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
660
666
  tabBar.addView(cell, LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 1f))
661
667
  tabViews.add(icon to label)
662
668
  }
663
- addView(tabBar, LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(64)))
664
- tabBar.visibility = if (tabs.isEmpty()) View.GONE else View.VISIBLE
669
+ tabRow.orientation = HORIZONTAL
670
+ tabRow.gravity = Gravity.CENTER_VERTICAL
671
+ tabRow.setPadding(dp(12), dp(4), dp(12), dp(4))
672
+ tabRow.addView(tabBar, LayoutParams(0, dp(64), 1f))
673
+ accessory.addView(accessoryIcon, FrameLayout.LayoutParams(dp(26), dp(26), Gravity.CENTER))
674
+ accessoryBadge.textSize = 10f
675
+ accessoryBadge.gravity = Gravity.CENTER
676
+ accessoryBadge.setTextColor(Color.WHITE)
677
+ accessoryBadge.setPadding(dp(4), 0, dp(4), 0)
678
+ accessoryBadge.background = GradientDrawable().apply { setColor(Color.RED); cornerRadius = dp(9).toFloat() }
679
+ accessory.addView(accessoryBadge, FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, dp(18), Gravity.TOP or Gravity.END))
680
+ accessory.setOnClickListener { accessorySpec?.optString("path")?.let { onAccessory?.invoke(it) } }
681
+ accessory.visibility = View.GONE
682
+ tabRow.addView(accessory, LayoutParams(dp(64), dp(64)).apply { marginStart = dp(8) })
683
+ addView(tabRow, LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(72)))
684
+ tabRow.visibility = if (tabs.isEmpty()) View.GONE else View.VISIBLE
665
685
  refreshColors()
666
686
  }
667
687
 
@@ -671,7 +691,7 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
671
691
  val overHorizontalScroll = onTabSwipeShouldDefer?.invoke(event.x, event.y) == true
672
692
  horizontalScrollDeferred = overHorizontalScroll
673
693
  if (overHorizontalScroll) parent?.requestDisallowInterceptTouchEvent(true)
674
- swipeTracking = tabs.size > 1 && tabSwipeEnabled && !overHorizontalScroll && tabBar.visibility == View.VISIBLE && event.y < tabBar.top
694
+ swipeTracking = tabs.size > 1 && tabSwipeEnabled && !overHorizontalScroll && tabRow.visibility == View.VISIBLE && event.y < tabRow.top
675
695
  swipeIntercepted = false
676
696
  swipeDownX = event.x
677
697
  swipeDownY = event.y
@@ -742,7 +762,17 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
742
762
  buttons.addView(navButton(button))
743
763
  }
744
764
  val tabBarHidden = options.optBoolean("tabBarHidden", false) || depth > 0 && options.optBoolean("tabBarHiddenWhenPushed", false)
745
- tabBar.visibility = if (tabs.isEmpty() || tabBarHidden) View.GONE else View.VISIBLE
765
+ tabRow.visibility = if (tabs.isEmpty() || tabBarHidden) View.GONE else View.VISIBLE
766
+ accessorySpec = options.optJSONObject("tabAccessory")?.takeIf {
767
+ val path = it.optString("path")
768
+ path.startsWith("/") && !path.startsWith("//") && it.has("title")
769
+ }
770
+ accessory.visibility = if (accessorySpec == null) View.GONE else View.VISIBLE
771
+ accessorySpec?.let {
772
+ accessory.contentDescription = it.optString("accessibilityLabel").ifBlank { it.optString("title") } + it.optString("badge").takeIf { badge -> badge.isNotBlank() && badge != "null" }?.let { badge -> ", $badge" }.orEmpty()
773
+ accessoryBadge.text = it.optString("badge").takeUnless { badge -> badge == "null" }.orEmpty().take(8)
774
+ accessoryBadge.visibility = if (accessoryBadge.text.isEmpty()) View.GONE else View.VISIBLE
775
+ }
746
776
  tabSwipeEnabled = options.optBoolean("tabSwipeEnabled", true)
747
777
  refreshColors()
748
778
  }
@@ -801,13 +831,10 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
801
831
  titleView.setTextColor(text)
802
832
  largeTitleView.setTextColor(text)
803
833
  backButton.imageTintList = ColorStateList.valueOf(accent)
804
- tabBar.setBackgroundColor(if (dark) Color.parseColor("#121212") else Color.parseColor("#F9F9F9"))
805
- val divider = GradientDrawable().apply { setColor(if (dark) Color.parseColor("#262626") else Color.parseColor("#D8D8DC")) }
806
- tabBar.foreground = null
807
- tabBar.background = android.graphics.drawable.LayerDrawable(arrayOf(
808
- divider,
809
- GradientDrawable().apply { setColor(if (dark) Color.parseColor("#121212") else Color.parseColor("#F9F9F9")) },
810
- )).apply { setLayerInset(1, 0, maxOf(1, (density / 2).toInt()), 0, 0) }
834
+ val surface = if (dark) Color.parseColor("#1C1C1E") else Color.parseColor("#F9F9F9")
835
+ tabBar.background = GradientDrawable().apply { setColor(surface); cornerRadius = dp(32).toFloat() }
836
+ accessory.background = GradientDrawable().apply { setColor(surface); cornerRadius = dp(32).toFloat() }
837
+ accessorySpec?.let { accessoryIcon.setImageDrawable(CraftNativeIcons.drawable(context, null, it.optString("symbol"), text)) }
811
838
  tabViews.forEachIndexed { index, (icon, label) ->
812
839
  val color = if (index == selectedTab) accent else muted
813
840
  val number = tabIconText[tabs[index].id]
@@ -148,6 +148,10 @@ private fun craftNativeOptionalString(value: Any?): String? = value
148
148
  ?.takeUnless { it == "null" || it == "undefined" }
149
149
  ?.takeIf { it.isNotBlank() }
150
150
 
151
+ private fun craftNativeBoolean(value: Any?, fallback: Boolean = false): Boolean = (value as? Boolean) ?: fallback
152
+
153
+ private fun craftNativeInt(value: Any?, fallback: Int): Int = (value as? Number)?.toInt() ?: fallback
154
+
151
155
  private fun craftNativePickerValue(value: Any?): String? = when (value) {
152
156
  is String -> value.takeIf { it.isNotEmpty() }
153
157
  is Number -> value.toDouble().takeIf { it.isFinite() }?.toString()
@@ -157,20 +161,35 @@ private fun craftNativePickerValue(value: Any?): String? = when (value) {
157
161
  private fun craftNativeEventName(events: JSONObject, vararg names: String): String =
158
162
  names.firstNotNullOfOrNull { name -> craftNativeOptionalString(events.opt(name)) }.orEmpty()
159
163
 
164
+ private fun craftNativeNumber(value: Any?): Double? = (value as? Number)
165
+ ?.toDouble()
166
+ ?.takeIf { it.isFinite() }
167
+
168
+ private fun craftNativeFontWeight(value: Any?): String? = when (value) {
169
+ is String -> value
170
+ is Number -> value.toDouble().takeIf { it.isFinite() && it % 1.0 == 0.0 }?.toInt()?.toString()
171
+ else -> null
172
+ }
173
+
160
174
  internal fun craftNativePadding(style: JSONObject?, name: String, fallback: Int, density: Float): Int {
161
- val value = style?.optDouble(name, Double.NaN) ?: Double.NaN
175
+ val value = craftNativeNumber(style?.opt(name)) ?: Double.NaN
162
176
  val pixels = value * density
163
177
  return if (value.isFinite() && value >= 0 && pixels.isFinite()) pixels.toInt() else fallback
164
178
  }
165
179
 
166
180
  internal fun craftNativeFiniteFloat(style: JSONObject?, name: String, fallback: Float): Float {
167
- val value = style?.optDouble(name, Double.NaN) ?: Double.NaN
181
+ val value = craftNativeNumber(style?.opt(name)) ?: Double.NaN
168
182
  val narrowed = value.toFloat()
169
183
  return if (value.isFinite() && narrowed.isFinite()) narrowed else fallback
170
184
  }
171
185
 
172
186
  internal fun craftNativeFiniteDouble(style: JSONObject?, name: String, fallback: Double): Double {
173
- val value = style?.optDouble(name, Double.NaN) ?: Double.NaN
187
+ val value = craftNativeNumber(style?.opt(name)) ?: Double.NaN
188
+ return value.takeIf { it.isFinite() } ?: fallback
189
+ }
190
+
191
+ internal fun craftNativeFiniteNumber(props: JSONObject?, name: String, fallback: Double): Double {
192
+ val value = (props?.opt(name) as? Number)?.toDouble() ?: Double.NaN
174
193
  return value.takeIf { it.isFinite() } ?: fallback
175
194
  }
176
195
 
@@ -221,32 +240,25 @@ private data class CraftNativeLayoutStyle(
221
240
  companion object {
222
241
  fun from(raw: JSONObject, density: Float): CraftNativeLayoutStyle {
223
242
  fun length(name: String): CraftNativeLength? = CraftNativeLength.from(raw.opt(name), density)
224
- fun number(name: String): Int? {
243
+ fun numeric(name: String): Double? {
225
244
  if (!raw.has(name) || raw.isNull(name)) return null
226
- return raw.optDouble(name, Double.NaN).takeIf { it.isFinite() }?.let { points ->
245
+ return (raw.opt(name) as? Number)?.toDouble()?.takeIf { it.isFinite() }
246
+ }
247
+ fun number(name: String): Int? {
248
+ return numeric(name)?.let { points ->
227
249
  (points * density).takeIf { it.isFinite() }?.roundToInt()
228
250
  }
229
251
  }
230
- fun decimal(name: String): Double? {
231
- if (!raw.has(name) || raw.isNull(name)) return null
232
- return raw.optDouble(name, Double.NaN).takeIf { it.isFinite() }
233
- }
252
+ fun decimal(name: String): Double? = numeric(name)
234
253
  val flex = decimal("flex")
235
254
  return CraftNativeLayoutStyle(
236
255
  width = length("width"), height = length("height"),
237
256
  aspectRatio = decimal("aspectRatio")?.takeIf { it.isFinite() && it > 0.0 },
238
257
  minWidth = length("minWidth"), maxWidth = length("maxWidth"),
239
258
  minHeight = length("minHeight"), maxHeight = length("maxHeight"),
240
- position = raw.opt("position")
241
- ?.takeUnless { it == JSONObject.NULL }
242
- ?.toString()
243
- ?.takeIf { it.isNotBlank() }
244
- ?: "relative",
259
+ position = craftNativeOptionalString(raw.opt("position")) ?: "relative",
245
260
  top = number("top"), right = number("right"), bottom = number("bottom"), left = number("left"),
246
- alignSelf = raw.opt("alignSelf")
247
- ?.takeUnless { it == JSONObject.NULL }
248
- ?.toString()
249
- ?.takeIf { it.isNotBlank() && it != "auto" },
261
+ alignSelf = craftNativeOptionalString(raw.opt("alignSelf"))?.takeUnless { it == "auto" },
250
262
  flexGrow = decimal("flexGrow") ?: flex?.coerceAtLeast(0.0) ?: 0.0,
251
263
  flexShrink = decimal("flexShrink") ?: flex?.let { if (it != 0.0) 1.0 else 0.0 },
252
264
  flexBasis = length("flexBasis")
@@ -364,11 +376,8 @@ private class CraftNativeSvgView(context: Context) : View(context) {
364
376
  fun merge(node: JSONObject): SvgPaint {
365
377
  val props = node.optJSONObject("props") ?: JSONObject()
366
378
  val style = node.optJSONObject("style") ?: JSONObject()
367
- fun value(name: String): String? = when {
368
- style.has(name) && !style.isNull(name) -> style.optString(name)
369
- props.has(name) && !props.isNull(name) -> props.optString(name)
370
- else -> null
371
- }
379
+ fun value(name: String): String? = craftNativeOptionalString(style.opt(name))
380
+ ?: craftNativeOptionalString(props.opt(name))
372
381
  fun raw(name: String): Any? = when {
373
382
  style.has(name) && !style.isNull(name) -> style.opt(name)
374
383
  props.has(name) && !props.isNull(name) -> props.opt(name)
@@ -377,7 +386,7 @@ private class CraftNativeSvgView(context: Context) : View(context) {
377
386
  fun dashes(rawValue: Any?): List<Float>? {
378
387
  val values = rawValue as? JSONArray ?: return null
379
388
  return (0 until values.length()).mapNotNull {
380
- values.optDouble(it, Double.NaN).toFloat().takeIf { value -> value.isFinite() && value >= 0 }
389
+ craftNativeNumber(values.opt(it))?.toFloat()?.takeIf { value -> value.isFinite() && value >= 0 }
381
390
  }
382
391
  }
383
392
  // Keep the cascade explicit: computed style wins over a raw prop
@@ -455,10 +464,10 @@ private class CraftNativeSvgView(context: Context) : View(context) {
455
464
  private fun viewBox(): Quadruple<Float, Float, Float, Float>? {
456
465
  val values = drawing.optJSONObject("props")?.optJSONArray("viewBox") ?: return null
457
466
  if (values.length() != 4) return null
458
- val x = values.optDouble(0, Double.NaN).toFloat()
459
- val y = values.optDouble(1, Double.NaN).toFloat()
460
- val width = values.optDouble(2, Double.NaN).toFloat()
461
- val height = values.optDouble(3, Double.NaN).toFloat()
467
+ val x = craftNativeNumber(values.opt(0))?.toFloat() ?: Float.NaN
468
+ val y = craftNativeNumber(values.opt(1))?.toFloat() ?: Float.NaN
469
+ val width = craftNativeNumber(values.opt(2))?.toFloat() ?: Float.NaN
470
+ val height = craftNativeNumber(values.opt(3))?.toFloat() ?: Float.NaN
462
471
  if (!x.isFinite() || !y.isFinite() || !width.isFinite() || !height.isFinite() || width <= 0f || height <= 0f) return null
463
472
  return Quadruple(width, height, x, y)
464
473
  }
@@ -467,7 +476,7 @@ private class CraftNativeSvgView(context: Context) : View(context) {
467
476
  for (index in 0 until children.length()) {
468
477
  val node = children.optJSONObject(index) ?: continue
469
478
  val style = node.optJSONObject("style") ?: JSONObject()
470
- if (style.optString("display") == "none") continue
479
+ if (craftNativeOptionalString(style.opt("display")) == "none") continue
471
480
  val paint = inherited.merge(node)
472
481
  when (node.optString("type")) {
473
482
  "G" -> drawNodes(canvas, node.optJSONArray("children") ?: JSONArray(), paint)
@@ -480,12 +489,12 @@ private class CraftNativeSvgView(context: Context) : View(context) {
480
489
  private fun drawPath(canvas: Canvas, node: JSONObject, style: SvgPaint) {
481
490
  val props = node.optJSONObject("props") ?: JSONObject()
482
491
  val geometry = props.optJSONArray("geometry") ?: return
483
- if ((0 until geometry.length()).any { !geometry.optDouble(it, Double.NaN).toFloat().isFinite() }) return
492
+ if ((0 until geometry.length()).any { craftNativeNumber(geometry.opt(it))?.toFloat()?.isFinite() != true }) return
484
493
  val path = Path()
485
494
  var index = 0
486
495
  while (index < geometry.length()) {
487
- val command = geometry.optDouble(index, Double.NaN).toInt()
488
- fun number(offset: Int): Float = geometry.optDouble(index + offset, Double.NaN).toFloat()
496
+ val command = craftNativeNumber(geometry.opt(index))?.toInt() ?: break
497
+ fun number(offset: Int): Float = craftNativeNumber(geometry.opt(index + offset))?.toFloat() ?: Float.NaN
489
498
  when (command) {
490
499
  0 -> if (index + 2 < geometry.length()) { path.moveTo(number(1), number(2)); index += 3 } else break
491
500
  1 -> if (index + 2 < geometry.length()) { path.lineTo(number(1), number(2)); index += 3 } else break
@@ -551,9 +560,9 @@ private class CraftNativeSvgView(context: Context) : View(context) {
551
560
  for (index in 0 until children.length()) if (children.opt(index) is String) append(children.optString(index))
552
561
  }
553
562
  if (text.isEmpty()) return
554
- val size = (if (props.has("fontSize")) props.optDouble("fontSize", 12.0) else nodeStyle.optDouble("fontSize", 12.0))
555
- .takeIf { it.isFinite() }
563
+ val size = (if (props.has("fontSize")) craftNativeNumber(props.opt("fontSize")) else craftNativeNumber(nodeStyle.opt("fontSize")))
556
564
  ?.toFloat()
565
+ ?.takeIf { it.isFinite() }
557
566
  ?: 12f
558
567
  val paint = android.graphics.Paint(Paint.ANTI_ALIAS_FLAG).apply {
559
568
  color = resolveColor(style.fill, style.color, Color.BLACK)
@@ -561,9 +570,9 @@ private class CraftNativeSvgView(context: Context) : View(context) {
561
570
  textSize = size
562
571
  typeface = style.typeface
563
572
  }
564
- var x = props.optDouble("x", 0.0).takeIf { it.isFinite() }?.toFloat() ?: 0f
565
- val y = props.optDouble("y", 0.0).takeIf { it.isFinite() }?.toFloat() ?: 0f
566
- when (props.optString("textAnchor")) {
573
+ var x = craftNativeNumber(props.opt("x"))?.toFloat()?.takeIf { it.isFinite() } ?: 0f
574
+ val y = craftNativeNumber(props.opt("y"))?.toFloat()?.takeIf { it.isFinite() } ?: 0f
575
+ when (craftNativeOptionalString(props.opt("textAnchor"))) {
567
576
  "middle" -> x -= paint.measureText(text) / 2f
568
577
  "end" -> x -= paint.measureText(text)
569
578
  }
@@ -629,16 +638,16 @@ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMap
629
638
 
630
639
  private fun applyPending() {
631
640
  val map = googleMap ?: return
632
- map.uiSettings.isScrollGesturesEnabled = pendingProps.optBoolean("scrollEnabled", true)
633
- map.uiSettings.isZoomGesturesEnabled = pendingProps.optBoolean("zoomEnabled", true)
634
- map.uiSettings.isRotateGesturesEnabled = pendingProps.optBoolean("rotateEnabled", false)
641
+ map.uiSettings.isScrollGesturesEnabled = craftNativeBoolean(pendingProps.opt("scrollEnabled"), true)
642
+ map.uiSettings.isZoomGesturesEnabled = craftNativeBoolean(pendingProps.opt("zoomEnabled"), true)
643
+ map.uiSettings.isRotateGesturesEnabled = craftNativeBoolean(pendingProps.opt("rotateEnabled"))
635
644
  map.mapType = when (craftNativeOptionalString(pendingProps.opt("mapType")).orEmpty().lowercase()) {
636
645
  "satellite" -> GoogleMap.MAP_TYPE_SATELLITE
637
646
  "hybrid" -> GoogleMap.MAP_TYPE_HYBRID
638
647
  "terrain" -> GoogleMap.MAP_TYPE_TERRAIN
639
648
  else -> GoogleMap.MAP_TYPE_NORMAL
640
649
  }
641
- val showUserLocation = pendingProps.optBoolean("showsUserLocation", false) && hasLocationPermission()
650
+ val showUserLocation = craftNativeBoolean(pendingProps.opt("showsUserLocation")) && hasLocationPermission()
642
651
  runCatching { map.isMyLocationEnabled = showUserLocation }
643
652
  val nextMarkers = mutableMapOf<String, Pair<LatLng, String?>>()
644
653
  val nextHandlers = mutableMapOf<String, String>()
@@ -654,7 +663,7 @@ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMap
654
663
  val points = coordinates(props.optJSONArray("geometry"))
655
664
  if (type == "mapmarker" || type == "marker") {
656
665
  points.firstOrNull()?.let { point ->
657
- nextMarkers[id] = point to craftNativeOptionalString(props.opt("title"))
666
+ nextMarkers[id] = point to (props.opt("title") as? String)
658
667
  listOf(events.opt("onPress"), events.opt("onMarkerPress"))
659
668
  .firstNotNullOfOrNull(::craftNativeOptionalString)
660
669
  ?.let { nextHandlers[id] = it }
@@ -717,7 +726,7 @@ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMap
717
726
  moveCamera(initialRegion)
718
727
  initialCameraKey = key
719
728
  }
720
- } else if (pendingProps.optBoolean("fitToBounds", false)) {
729
+ } else if (craftNativeBoolean(pendingProps.opt("fitToBounds"))) {
721
730
  val key = "fit:${overlayKey(nextMarkers, nextPolylines)}"
722
731
  if (initialCameraKey != key) {
723
732
  if (fitToBounds()) initialCameraKey = key
@@ -731,7 +740,7 @@ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMap
731
740
  super.onSizeChanged(width, height, oldWidth, oldHeight)
732
741
  // MapView can become ready before its host has a usable viewport. Do
733
742
  // not mark a fit as complete until the camera update can be issued.
734
- if (width > 0 && height > 0 && pendingProps.optBoolean("fitToBounds", false) && initialCameraKey == null) {
743
+ if (width > 0 && height > 0 && craftNativeBoolean(pendingProps.opt("fitToBounds")) && initialCameraKey == null) {
735
744
  applyPending()
736
745
  }
737
746
  }
@@ -1008,16 +1017,17 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
1008
1017
  }
1009
1018
 
1010
1019
  fun configure(style: JSONObject, density: Float) {
1011
- orientation = if (style.optString("flexDirection") in setOf("row", "row-reverse")) HORIZONTAL else VERTICAL
1012
- reverse = style.optString("flexDirection").endsWith("-reverse")
1013
- val flexWrap = style.optString("flexWrap")
1014
- wrap = flexWrap == "wrap" || flexWrap == "wrap-reverse" || style.optBoolean("flexWrap", false)
1020
+ val flexDirection = craftNativeOptionalString(style.opt("flexDirection")).orEmpty()
1021
+ orientation = if (flexDirection in setOf("row", "row-reverse")) HORIZONTAL else VERTICAL
1022
+ reverse = flexDirection.endsWith("-reverse")
1023
+ val flexWrap = craftNativeOptionalString(style.opt("flexWrap")).orEmpty()
1024
+ wrap = flexWrap == "wrap" || flexWrap == "wrap-reverse" || craftNativeBoolean(style.opt("flexWrap"))
1015
1025
  wrapReverse = flexWrap == "wrap-reverse"
1016
- alignItems = style.optString("alignItems", "stretch")
1017
- alignContent = style.optString("alignContent", "flex-start")
1018
- justifyContent = style.optString("justifyContent", "flex-start")
1026
+ alignItems = craftNativeOptionalString(style.opt("alignItems")) ?: "stretch"
1027
+ alignContent = craftNativeOptionalString(style.opt("alignContent")) ?: "flex-start"
1028
+ justifyContent = craftNativeOptionalString(style.opt("justifyContent")) ?: "flex-start"
1019
1029
  fun spacing(name: String, fallback: Float): Float {
1020
- val value = style.optDouble(name, Double.NaN)
1030
+ val value = craftNativeNumber(style.opt(name)) ?: Double.NaN
1021
1031
  val pixels = value * density
1022
1032
  val narrowed = pixels.toFloat()
1023
1033
  return if (value.isFinite() && value >= 0 && pixels.isFinite() && narrowed.isFinite()) narrowed else fallback
@@ -1025,7 +1035,7 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
1025
1035
  val gap = spacing("gap", 0f)
1026
1036
  rowGap = spacing("rowGap", gap)
1027
1037
  columnGap = spacing("columnGap", gap)
1028
- grid = style.optString("display") == "grid"
1038
+ grid = craftNativeOptionalString(style.opt("display")) == "grid"
1029
1039
  gridColumns = craftNativeGridColumnCount(
1030
1040
  if (style.has("gridTemplateColumns")) style.opt("gridTemplateColumns") else style.opt("gridColumns")
1031
1041
  )
@@ -2076,7 +2086,7 @@ private class CraftNativeKeyboardAvoidingView(context: Context) : FrameLayout(co
2076
2086
  }
2077
2087
 
2078
2088
  fun configure(props: JSONObject, style: JSONObject, density: Float) {
2079
- behavior = props.optString("behavior", "padding").takeIf { it in setOf("height", "position", "padding") } ?: "padding"
2089
+ behavior = craftNativeOptionalString(props.opt("behavior"))?.takeIf { it in setOf("height", "position", "padding") } ?: "padding"
2080
2090
  val authoredOffset = craftNativeFiniteDouble(props, "keyboardVerticalOffset", 0.0)
2081
2091
  val pixelOffset = (authoredOffset * density.toDouble()).takeIf { it.isFinite() } ?: 0.0
2082
2092
  keyboardVerticalOffset = pixelOffset.roundToInt()
@@ -2734,7 +2744,7 @@ private class CraftNativeDatePickerView(context: Context) : FrameLayout(context)
2734
2744
  }
2735
2745
 
2736
2746
  fun configure(props: JSONObject) {
2737
- val requestedMode = when (props.optString("mode")) {
2747
+ val requestedMode = when (props.opt("mode") as? String) {
2738
2748
  "time" -> PickerMode.TIME
2739
2749
  "datetime", "dateAndTime", "datetime-local" -> PickerMode.DATE_TIME
2740
2750
  "countdown" -> PickerMode.COUNTDOWN
@@ -2774,7 +2784,7 @@ private class CraftNativeDatePickerView(context: Context) : FrameLayout(context)
2774
2784
  }
2775
2785
 
2776
2786
  suppressChanges = true
2777
- minuteInterval = props.optInt("minuteInterval", 1).takeIf { it in 1..30 && 60 % it == 0 } ?: 1
2787
+ minuteInterval = craftNativeInt(props.opt("minuteInterval"), 1).takeIf { it in 1..30 && 60 % it == 0 } ?: 1
2778
2788
  val defaults = defaultDateBounds
2779
2789
  datePicker?.minDate = pickerDate(props.opt("minimumDate"))?.timeInMillis ?: defaults?.first ?: datePicker?.minDate ?: 0L
2780
2790
  datePicker?.maxDate = pickerDate(props.opt("maximumDate"))?.timeInMillis ?: defaults?.second ?: datePicker?.maxDate ?: Long.MAX_VALUE
@@ -2784,7 +2794,7 @@ private class CraftNativeDatePickerView(context: Context) : FrameLayout(context)
2784
2794
  } else {
2785
2795
  pickerDate(props.opt("value"))?.let { applyDate(it) }
2786
2796
  }
2787
- val enabled = !props.optBoolean("disabled", false)
2797
+ val enabled = !craftNativeBoolean(props.opt("disabled"))
2788
2798
  isEnabled = enabled
2789
2799
  datePicker?.isEnabled = enabled
2790
2800
  timePicker?.isEnabled = enabled
@@ -3939,6 +3949,7 @@ class MainActivity : AppCompatActivity() {
3939
3949
  val bars = CraftNativeChrome(this, routes.tabs)
3940
3950
  bars.layoutParams = FrameLayout.LayoutParams(-1, -1)
3941
3951
  bars.onBack = { onBackPressedDispatcherCompat() }
3952
+ bars.onAccessory = { path -> navigateOpen(path) }
3942
3953
  bars.onButton = { id -> visibleScreen?.takeIf { it.active }?.let { send(it, "NAV_BUTTON", JSONObject().put("id", id)) } }
3943
3954
  bars.onTab = { index ->
3944
3955
  haptic()
@@ -4925,8 +4936,8 @@ class MainActivity : AppCompatActivity() {
4925
4936
  val control = when (type) {
4926
4937
  "Text" -> ((previous as? TextView)?.takeUnless { it is Button || it is EditText } ?: TextView(this)).apply {
4927
4938
  includeFontPadding = false
4928
- maxLines = props.optInt("numberOfLines", 0).takeIf { it > 0 } ?: Int.MAX_VALUE
4929
- ellipsize = when (props.optString("ellipsizeMode")) {
4939
+ maxLines = craftNativeInt(props.opt("numberOfLines"), 0).takeIf { it > 0 } ?: Int.MAX_VALUE
4940
+ ellipsize = when (craftNativeOptionalString(props.opt("ellipsizeMode"))) {
4930
4941
  "head" -> TextUtils.TruncateAt.START
4931
4942
  "middle" -> TextUtils.TruncateAt.MIDDLE
4932
4943
  "tail" -> TextUtils.TruncateAt.END
@@ -4936,7 +4947,7 @@ class MainActivity : AppCompatActivity() {
4936
4947
  }
4937
4948
  "StatusBar" -> (previous as? CraftNativeFlexLayout ?: CraftNativeFlexLayout(this)).apply {
4938
4949
  screen.statusBarStyle = craftNativeOptionalString(props.opt("barStyle"))
4939
- screen.statusBarHidden = if (props.has("hidden") && !props.isNull("hidden")) props.optBoolean("hidden") else null
4950
+ screen.statusBarHidden = if (props.has("hidden") && !props.isNull("hidden")) craftNativeBoolean(props.opt("hidden")) else null
4940
4951
  visibility = View.GONE
4941
4952
  }
4942
4953
  "RefreshControl" -> (previous as? CraftNativeFlexLayout ?: CraftNativeFlexLayout(this)).apply {
@@ -4944,17 +4955,18 @@ class MainActivity : AppCompatActivity() {
4944
4955
  }
4945
4956
  "Link" -> ((previous as? TextView)?.takeUnless { it is Button } ?: TextView(this)).apply {
4946
4957
  includeFontPadding = false
4947
- configureText(this, craftNativeOptionalString(props.opt("title")) ?: childText(children), style)
4958
+ configureText(this, (props.opt("title") as? String) ?: childText(children), style)
4948
4959
  val linkColor = craftNativeOptionalString(props.opt("color")) ?: craftNativeOptionalString(style.opt("color"))
4949
4960
  setTextColor(color(linkColor.orEmpty(), Color.rgb(33, 150, 243)))
4950
4961
  paintFlags = paintFlags or Paint.UNDERLINE_TEXT_FLAG
4951
- isEnabled = !props.optBoolean("disabled", false)
4962
+ isEnabled = !craftNativeBoolean(props.opt("disabled"))
4952
4963
  isClickable = true
4953
4964
  isFocusable = true
4954
4965
  val target = listOf(props.opt("href"), props.opt("to"))
4955
- .firstNotNullOfOrNull(::craftNativeOptionalString)
4966
+ .mapNotNull { it as? String }
4967
+ .firstOrNull { it.trim().isNotEmpty() }
4956
4968
  ?.trim()
4957
- ?.takeIf { !props.optBoolean("disabled", false) }
4969
+ ?.takeIf { !craftNativeBoolean(props.opt("disabled")) }
4958
4970
  if (target != null) screen.linkTargets[this] = target else screen.linkTargets.remove(this)
4959
4971
  val handler = craftNativeEventName(events, "onPress", "onClick")
4960
4972
  if (handler.isNotBlank()) screen.handlers[this] = handler else screen.handlers.remove(this)
@@ -4975,13 +4987,13 @@ class MainActivity : AppCompatActivity() {
4975
4987
  minimumHeight = 0
4976
4988
  setPadding(0, 0, 0, 0)
4977
4989
  }).apply {
4978
- val title = craftNativeOptionalString(props.opt("title")) ?: childText(children)
4990
+ val title = (props.opt("title") as? String) ?: childText(children)
4979
4991
  isAllCaps = false
4980
4992
  configureText(this, title, style)
4981
4993
  val buttonColor = craftNativeOptionalString(props.opt("color"))
4982
4994
  ?: craftNativeOptionalString(style.opt("color"))
4983
4995
  setTextColor(buttonColor?.let { color(it, defaultTextColor) } ?: defaultTextColor)
4984
- isEnabled = !props.optBoolean("disabled", false)
4996
+ isEnabled = !craftNativeBoolean(props.opt("disabled"))
4985
4997
  val handler = craftNativeEventName(events, "onPress", "onClick")
4986
4998
  if (handler.isNotBlank()) screen.handlers[this] = handler else screen.handlers.remove(this)
4987
4999
  setOnClickListener {
@@ -4990,7 +5002,7 @@ class MainActivity : AppCompatActivity() {
4990
5002
  }
4991
5003
  }
4992
5004
  "Checkbox" -> (previous as? CheckBox ?: CheckBox(this)).apply {
4993
- text = craftNativeOptionalString(props.opt("label")) ?: childText(children)
5005
+ text = (props.opt("label") as? String) ?: childText(children)
4994
5006
  configureText(this, text.toString(), style)
4995
5007
  val checkboxChecked = props.opt("checked")
4996
5008
  val checkboxValue = props.opt("value")
@@ -5006,7 +5018,7 @@ class MainActivity : AppCompatActivity() {
5006
5018
  isChecked = checked
5007
5019
  val indeterminate = (props.opt("indeterminate") as? Boolean) == true
5008
5020
  isActivated = indeterminate
5009
- isEnabled = !props.optBoolean("disabled", false)
5021
+ isEnabled = !craftNativeBoolean(props.opt("disabled"))
5010
5022
  val checkboxColor = craftNativeOptionalString(props.opt("color"))
5011
5023
  ?: craftNativeOptionalString(style.opt("color"))
5012
5024
  buttonTintList = checkboxColor
@@ -5035,17 +5047,17 @@ class MainActivity : AppCompatActivity() {
5035
5047
  val defaultPadding = inputPaddingDefaults.getOrPut(this) {
5036
5048
  intArrayOf(paddingLeft, paddingTop, paddingRight, paddingBottom)
5037
5049
  }
5038
- hint = craftNativeOptionalString(props.opt("placeholder")).orEmpty()
5050
+ hint = (props.opt("placeholder") as? String).orEmpty()
5039
5051
  setHintTextColor(
5040
- craftNativeOptionalString(props.opt("placeholderTextColor"))
5052
+ (props.opt("placeholderTextColor") as? String)
5041
5053
  ?.let { runCatching { craftNativeColor(it) }.getOrNull() }
5042
5054
  ?.let { ColorStateList.valueOf(it) }
5043
5055
  ?: defaultHintColors,
5044
5056
  )
5045
- highlightColor = craftNativeOptionalString(props.opt("selectionColor"))
5057
+ highlightColor = (props.opt("selectionColor") as? String)
5046
5058
  ?.let { runCatching { craftNativeColor(it) }.getOrNull() }
5047
5059
  ?: defaultHighlightColor
5048
- val maxLength = props.optInt("maxLength", 0)
5060
+ val maxLength = craftNativeInt(props.opt("maxLength"), 0)
5049
5061
  val currentLength = (filters.firstOrNull { it is InputFilter.LengthFilter } as? InputFilter.LengthFilter)?.max ?: 0
5050
5062
  if (currentLength != maxLength || (maxLength == 0 && filters.isNotEmpty())) {
5051
5063
  filters = if (maxLength > 0) arrayOf(InputFilter.LengthFilter(maxLength)) else emptyArray()
@@ -5054,11 +5066,11 @@ class MainActivity : AppCompatActivity() {
5054
5066
  if (inputType != wantedType) inputType = wantedType
5055
5067
  val wantedIme = imeOptionsFor(props)
5056
5068
  if (imeOptions != wantedIme) imeOptions = wantedIme
5057
- val multiline = props.optBoolean("multiline", false)
5069
+ val multiline = craftNativeBoolean(props.opt("multiline"))
5058
5070
  if (isSingleLine == multiline) isSingleLine = !multiline
5059
- val wantedLines = if (multiline) props.optInt("numberOfLines", Int.MAX_VALUE).coerceAtLeast(1) else 1
5071
+ val wantedLines = if (multiline) craftNativeInt(props.opt("numberOfLines"), Int.MAX_VALUE).coerceAtLeast(1) else 1
5060
5072
  if (maxLines != wantedLines) maxLines = wantedLines
5061
- isEnabled = props.optBoolean("editable", true)
5073
+ isEnabled = craftNativeBoolean(props.opt("editable"), true)
5062
5074
  configureTextStyle(this, style)
5063
5075
  // The style's padding is where the text starts, as on iOS and
5064
5076
  // the web; without one the field keeps its own.
@@ -5086,14 +5098,16 @@ class MainActivity : AppCompatActivity() {
5086
5098
  // them after keyed reconciliation.
5087
5099
  val autofillHint = autofillHintFor(props)
5088
5100
  if (autofillHint == null) setAutofillHints(*emptyArray()) else setAutofillHints(autofillHint)
5089
- val controlledValue = props.opt("value")?.takeUnless { it == JSONObject.NULL }?.toString()
5101
+ val controlledValue = props.opt("value") as? String
5090
5102
  val echoes = screen.inputEchoes.getOrPut(this) { CraftNativeInputEcho() }
5091
5103
  if (echoes.accept(controlledValue, text.toString()) && controlledValue != null) {
5092
5104
  screen.suppressedTextChanges.add(this)
5093
5105
  setTextPreservingSelection(this, controlledValue)
5094
5106
  }
5095
5107
  else if (this !== previous && screen.drafts.containsKey(identity)) setText(screen.drafts[identity])
5096
- else if (this !== previous && props.has("defaultValue") && !props.isNull("defaultValue")) setText(props.optString("defaultValue"))
5108
+ else if (this !== previous && props.has("defaultValue") && !props.isNull("defaultValue")) {
5109
+ (props.opt("defaultValue") as? String)?.let(::setText)
5110
+ }
5097
5111
  applyTextDecoration(this, style)
5098
5112
  screen.inputIdentities[this] = identity
5099
5113
  val handler = craftNativeEventName(events, "onChange", "onChangeText")
@@ -5128,7 +5142,7 @@ class MainActivity : AppCompatActivity() {
5128
5142
  send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject().put("text", text.toString())))
5129
5143
  true
5130
5144
  }
5131
- if (props.optBoolean("autoFocus", false) && !isFocused) post {
5145
+ if (craftNativeBoolean(props.opt("autoFocus")) && !isFocused) post {
5132
5146
  if (!isFocused) {
5133
5147
  requestFocus()
5134
5148
  (getSystemService(INPUT_METHOD_SERVICE) as? android.view.inputmethod.InputMethodManager)
@@ -5158,7 +5172,7 @@ class MainActivity : AppCompatActivity() {
5158
5172
  val itemOptions = props.optJSONArray("items")?.let { items ->
5159
5173
  (0 until items.length()).mapNotNull { index ->
5160
5174
  val item = items.optJSONObject(index) ?: return@mapNotNull null
5161
- val label = craftNativeOptionalString(item.opt("label")) ?: return@mapNotNull null
5175
+ val label = (item.opt("label") as? String)?.takeIf { it.isNotEmpty() } ?: return@mapNotNull null
5162
5176
  craftNativePickerValue(item.opt("value"))?.let { it to label } ?: (label to label)
5163
5177
  }
5164
5178
  }.orEmpty()
@@ -5166,7 +5180,7 @@ class MainActivity : AppCompatActivity() {
5166
5180
  val option = children.optJSONObject(index) ?: return@mapNotNull null
5167
5181
  if (option.optString("type") != "Text") return@mapNotNull null
5168
5182
  val label = (option.optJSONArray("children") ?: JSONArray()).let { text ->
5169
- (0 until text.length()).joinToString("") { text.optString(it) }
5183
+ (0 until text.length()).mapNotNull { text.opt(it) as? String }.joinToString("")
5170
5184
  }
5171
5185
  if (label.isBlank()) return@mapNotNull null
5172
5186
  val optionProps = option.optJSONObject("props")
@@ -5185,7 +5199,7 @@ class MainActivity : AppCompatActivity() {
5185
5199
  val requested = craftNativePickerValue(props.opt("selectedValue"))
5186
5200
  ?: craftNativePickerValue(props.opt("value")).orEmpty()
5187
5201
  val selected = options.indexOfFirst { it.first == requested }.takeIf { it >= 0 } ?: 0
5188
- isEnabled = !props.optBoolean("disabled", false)
5202
+ isEnabled = !craftNativeBoolean(props.opt("disabled"))
5189
5203
  // Mounting a controlled picker must not look like a user
5190
5204
  // change. Android may dispatch the initial adapter selection
5191
5205
  // during the first layout pass; iOS only reports a choice
@@ -5215,7 +5229,7 @@ class MainActivity : AppCompatActivity() {
5215
5229
  }
5216
5230
  }
5217
5231
  "Modal" -> (previous as? CraftNativeModalView ?: CraftNativeModalView(this)).apply {
5218
- transparent = props.optBoolean("transparent", false)
5232
+ transparent = craftNativeBoolean(props.opt("transparent"))
5219
5233
  content.configure(style, resources.displayMetrics.density)
5220
5234
  reconcileChildren(screen, content, children, identity, next, style, previousControls)
5221
5235
  // Dynamic :visible bindings arrive as a display style mutation;
@@ -5224,9 +5238,9 @@ class MainActivity : AppCompatActivity() {
5224
5238
  // An explicit visible prop owns modal visibility. The
5225
5239
  // binding runtime may leave a stale display:none style
5226
5240
  // from the previous hidden state when it patches props.
5227
- props.optBoolean("visible", false)
5241
+ craftNativeBoolean(props.opt("visible"))
5228
5242
  } else {
5229
- style.has("display") && style.optString("display") != "none"
5243
+ style.has("display") && craftNativeOptionalString(style.opt("display")) != "none"
5230
5244
  }
5231
5245
  val wasVisible = screen.modalVisibility[this]
5232
5246
  screen.modalVisibility[this] = visible
@@ -5260,7 +5274,7 @@ class MainActivity : AppCompatActivity() {
5260
5274
  }
5261
5275
  "TouchableOpacity", "TouchableHighlight", "Pressable" -> (previous as? CraftNativeFlexLayout ?: CraftNativeFlexLayout(this)).apply {
5262
5276
  configureContainer(this, style)
5263
- val disabled = props.optBoolean("disabled", false)
5277
+ val disabled = craftNativeBoolean(props.opt("disabled"))
5264
5278
  screen.pressActiveOpacities[this] = craftNativeFiniteFloat(props, "activeOpacity", 0.5f)
5265
5279
  screen.pressBaseOpacities[this] = craftNativeFiniteFloat(style, "opacity", 1f)
5266
5280
  if (type == "TouchableHighlight" && !disabled) {
@@ -5281,7 +5295,7 @@ class MainActivity : AppCompatActivity() {
5281
5295
  requestedChecked is Boolean -> requestedChecked
5282
5296
  else -> false
5283
5297
  }
5284
- isEnabled = !props.optBoolean("disabled", false)
5298
+ isEnabled = !craftNativeBoolean(props.opt("disabled"))
5285
5299
  props.optJSONObject("trackColor")?.let { track ->
5286
5300
  val falseRaw = craftNativeOptionalString(track.opt("false"))
5287
5301
  val trueRaw = craftNativeOptionalString(track.opt("true"))
@@ -5309,11 +5323,11 @@ class MainActivity : AppCompatActivity() {
5309
5323
  }
5310
5324
  }
5311
5325
  "Slider" -> (previous as? SeekBar ?: SeekBar(this)).apply {
5312
- val minimum = craftNativeFiniteDouble(props, "minimumValue", 0.0)
5313
- val maximumCandidate = craftNativeFiniteDouble(props, "maximumValue", 1.0)
5326
+ val minimum = craftNativeFiniteNumber(props, "minimumValue", 0.0)
5327
+ val maximumCandidate = craftNativeFiniteNumber(props, "maximumValue", 1.0)
5314
5328
  val maximum = if (maximumCandidate > minimum) maximumCandidate else minimum + Double.MIN_VALUE
5315
5329
  setOnSeekBarChangeListener(null)
5316
- val step = craftNativeFiniteDouble(props, "step", 0.0).takeIf { it > 0 }
5330
+ val step = craftNativeFiniteNumber(props, "step", 0.0).takeIf { it > 0 }
5317
5331
  // A stepped slider needs one native position per authored
5318
5332
  // step; a fixed 1,000-position SeekBar silently made large
5319
5333
  // ranges unable to represent most valid values. Continuous
@@ -5327,7 +5341,7 @@ class MainActivity : AppCompatActivity() {
5327
5341
  .toInt()
5328
5342
  } ?: 1000
5329
5343
  max = progressMax
5330
- val requested = craftNativeFiniteDouble(props, "value", minimum)
5344
+ val requested = craftNativeFiniteNumber(props, "value", minimum)
5331
5345
  val value = (if (step != null) minimum + ((requested - minimum) / step).roundToInt() * step else requested)
5332
5346
  .coerceIn(minimum, maximum)
5333
5347
  progress = if (step != null) {
@@ -5345,7 +5359,7 @@ class MainActivity : AppCompatActivity() {
5345
5359
  thumbTintList = craftNativeOptionalString(props.opt("thumbTintColor"))
5346
5360
  ?.let { runCatching { craftNativeColor(it) }.getOrNull() }
5347
5361
  ?.let { ColorStateList.valueOf(it) }
5348
- isEnabled = !props.optBoolean("disabled", false)
5362
+ isEnabled = !craftNativeBoolean(props.opt("disabled"))
5349
5363
  val handler = craftNativeEventName(events, "onValueChange", "onChange")
5350
5364
  if (handler.isNotBlank()) screen.handlers[this] = handler else screen.handlers.remove(this)
5351
5365
  val complete = craftNativeEventName(events, "onSlidingComplete")
@@ -5379,9 +5393,9 @@ class MainActivity : AppCompatActivity() {
5379
5393
  else -> 1f
5380
5394
  }
5381
5395
  indicatorScale = scale
5382
- val animating = props.optBoolean("animating", true)
5396
+ val animating = craftNativeBoolean(props.opt("animating"), true)
5383
5397
  isIndeterminate = animating
5384
- visibility = if (!animating && props.optBoolean("hidesWhenStopped", true)) View.GONE else View.VISIBLE
5398
+ visibility = if (!animating && craftNativeBoolean(props.opt("hidesWhenStopped"), true)) View.GONE else View.VISIBLE
5385
5399
  indeterminateTintList = craftNativeOptionalString(props.opt("color"))
5386
5400
  ?.let { runCatching { craftNativeColor(it) }.getOrNull() }
5387
5401
  ?.let { ColorStateList.valueOf(it) }
@@ -5437,13 +5451,13 @@ class MainActivity : AppCompatActivity() {
5437
5451
  onPictureInPictureChanged = { updatePictureInPicture() }
5438
5452
  configure(
5439
5453
  source = props.opt("source") ?: props.opt("src"),
5440
- autoplay = props.optBoolean("autoplay", props.optBoolean("autoPlay", false)),
5441
- paused = props.optBoolean("paused", false),
5442
- loop = props.optBoolean("loop", false),
5443
- muted = props.optBoolean("muted", false),
5444
- controls = props.optBoolean("controls", false),
5454
+ autoplay = craftNativeBoolean(props.opt("autoplay"), craftNativeBoolean(props.opt("autoPlay"))),
5455
+ paused = craftNativeBoolean(props.opt("paused")),
5456
+ loop = craftNativeBoolean(props.opt("loop")),
5457
+ muted = craftNativeBoolean(props.opt("muted")),
5458
+ controls = craftNativeBoolean(props.opt("controls")),
5445
5459
  resizeMode = craftNativeResizeMode(style, props),
5446
- pictureInPicture = props.optBoolean("pictureInPicture", false),
5460
+ pictureInPicture = craftNativeBoolean(props.opt("pictureInPicture")),
5447
5461
  )
5448
5462
  }
5449
5463
  "Map" -> (previous as? CraftNativeMapView ?: CraftNativeMapView(this)).apply {
@@ -5458,21 +5472,21 @@ class MainActivity : AppCompatActivity() {
5458
5472
  drawing = node
5459
5473
  }
5460
5474
  "ScrollView", "SectionList" -> (previous as? CraftNativeScrollView ?: CraftNativeScrollView(this)).apply {
5461
- val horizontal = props.optBoolean("horizontal", false) || style.optString("flexDirection") in setOf("row", "row-reverse")
5475
+ val horizontal = craftNativeBoolean(props.opt("horizontal")) || craftNativeOptionalString(style.opt("flexDirection")) in setOf("row", "row-reverse")
5462
5476
  setHorizontal(horizontal)
5463
5477
  setNativeNestedScrollingEnabled(true)
5464
5478
  setScrollIndicators(
5465
- props.optBoolean("showsVerticalScrollIndicator", true),
5466
- props.optBoolean("showsHorizontalScrollIndicator", true),
5479
+ craftNativeBoolean(props.opt("showsVerticalScrollIndicator"), true),
5480
+ craftNativeBoolean(props.opt("showsHorizontalScrollIndicator"), true),
5467
5481
  )
5468
- setScrollEnabled(props.optBoolean("scrollEnabled", true))
5469
- setKeyboardShouldPersistTaps(props.optString("keyboardShouldPersistTaps", "never"))
5470
- setBounces(props.optBoolean("bounces", true))
5482
+ setScrollEnabled(craftNativeBoolean(props.opt("scrollEnabled"), true))
5483
+ setKeyboardShouldPersistTaps(craftNativeOptionalString(props.opt("keyboardShouldPersistTaps")) ?: "never")
5484
+ setBounces(craftNativeBoolean(props.opt("bounces"), true))
5471
5485
  setAlwaysBounce(
5472
- props.optBoolean("alwaysBounceVertical", !horizontal),
5473
- props.optBoolean("alwaysBounceHorizontal", horizontal),
5486
+ craftNativeBoolean(props.opt("alwaysBounceVertical"), !horizontal),
5487
+ craftNativeBoolean(props.opt("alwaysBounceHorizontal"), horizontal),
5474
5488
  )
5475
- setPagingEnabled(props.optBoolean("pagingEnabled", false))
5489
+ setPagingEnabled(craftNativeBoolean(props.opt("pagingEnabled")))
5476
5490
  val refreshNode = (0 until children.length()).asSequence()
5477
5491
  .mapNotNull { children.optJSONObject(it) }
5478
5492
  .firstOrNull { canonicalType(it.optString("type")) == "RefreshControl" }
@@ -5485,7 +5499,7 @@ class MainActivity : AppCompatActivity() {
5485
5499
  val refreshHandler = refreshHandlerName?.let { handler ->
5486
5500
  { send(screen, "EVENT", JSONObject().put("handlerName", handler).put("nativeEvent", JSONObject())) }
5487
5501
  }
5488
- setRefreshHandler(refreshHandler, refreshProps.optBoolean("refreshing", props.optBoolean("refreshing", false)))
5502
+ setRefreshHandler(refreshHandler, craftNativeBoolean(refreshProps.opt("refreshing"), craftNativeBoolean(props.opt("refreshing"))))
5489
5503
  setRefreshAppearance(refreshProps, resources.displayMetrics.density, refreshTintFallback(screen))
5490
5504
  val onScroll = craftNativeEventName(events, "onScroll")
5491
5505
  if (onScroll.isNotBlank()) screen.scrollHandlers[this] = onScroll else screen.scrollHandlers.remove(this)
@@ -5528,7 +5542,7 @@ class MainActivity : AppCompatActivity() {
5528
5542
  send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject()))
5529
5543
  }
5530
5544
  },
5531
- keyboardDismissMode = props.optString("keyboardDismissMode"),
5545
+ keyboardDismissMode = craftNativeOptionalString(props.opt("keyboardDismissMode")).orEmpty(),
5532
5546
  dismissKeyboard = {
5533
5547
  (getSystemService(INPUT_METHOD_SERVICE) as? android.view.inputmethod.InputMethodManager)
5534
5548
  ?.hideSoftInputFromWindow(nativeScroll.windowToken, 0)
@@ -5563,11 +5577,11 @@ class MainActivity : AppCompatActivity() {
5563
5577
  ?: craftNativeEventName(events, "onRefresh").takeIf { it.isNotBlank() }
5564
5578
  configureRefresh(refreshHandlerName?.let { handler ->
5565
5579
  { send(screen, "EVENT", JSONObject().put("handlerName", handler).put("nativeEvent", JSONObject())) }
5566
- }, propRefresh.optBoolean("refreshing", props.optBoolean("refreshing", false)))
5580
+ }, craftNativeBoolean(propRefresh.opt("refreshing"), craftNativeBoolean(props.opt("refreshing"))))
5567
5581
  setRefreshAppearance(propRefresh, refreshTintFallback(screen))
5568
- nativeList.setKeyboardShouldPersistTaps(props.optString("keyboardShouldPersistTaps", "never"))
5582
+ nativeList.setKeyboardShouldPersistTaps(craftNativeOptionalString(props.opt("keyboardShouldPersistTaps")) ?: "never")
5569
5583
  nativeList.setContentContainerStyle(props.optJSONObject("contentContainerStyle"), resources.displayMetrics.density)
5570
- nativeList.setKeyboardDismissMode(props.optString("keyboardDismissMode")) {
5584
+ nativeList.setKeyboardDismissMode(craftNativeOptionalString(props.opt("keyboardDismissMode")).orEmpty()) {
5571
5585
  (getSystemService(INPUT_METHOD_SERVICE) as? android.view.inputmethod.InputMethodManager)
5572
5586
  ?.hideSoftInputFromWindow(windowToken, 0)
5573
5587
  }
@@ -5576,6 +5590,7 @@ class MainActivity : AppCompatActivity() {
5576
5590
  "View", "SafeAreaView" -> (previous as? CraftNativeFlexLayout ?: CraftNativeFlexLayout(this)).apply {
5577
5591
  configureContainer(this, style)
5578
5592
  if (type == "SafeAreaView") setSafeAreaEdges(props.optJSONArray("edges"))
5593
+ else setWindowSafeInsets(0, 0, 0, 0)
5579
5594
  reconcileChildren(screen, this, children, identity, next, style, previousControls)
5580
5595
  }
5581
5596
  "KeyboardAvoidingView" -> (previous as? CraftNativeKeyboardAvoidingView ?: CraftNativeKeyboardAvoidingView(this)).apply {
@@ -5603,7 +5618,7 @@ class MainActivity : AppCompatActivity() {
5603
5618
  if (underlayHideHandler != null) screen.pressUnderlayHideHandlers[control] = underlayHideHandler else screen.pressUnderlayHideHandlers.remove(control)
5604
5619
  if (type != "Button" && type != "Link" && type != "Checkbox" && type != "TextInput" && type != "Picker" && type != "DatePicker" && type != "Modal" && type != "Switch" && type != "Slider" && type != "ActivityIndicator" && type != "KeyboardAvoidingView" && type != "StatusBar" && type != "RefreshControl" && type != "Map") {
5605
5620
  val handler = craftNativeEventName(events, "onPress", "onClick")
5606
- val disabled = props.optBoolean("disabled", false)
5621
+ val disabled = craftNativeBoolean(props.opt("disabled"))
5607
5622
  val target = listOf(props.opt("href"), props.opt("to"))
5608
5623
  .firstNotNullOfOrNull(::craftNativeOptionalString)
5609
5624
  ?.trim()
@@ -5674,8 +5689,8 @@ class MainActivity : AppCompatActivity() {
5674
5689
  }
5675
5690
  control.isEnabled = !disabled
5676
5691
  }
5677
- val longPressDisabled = props.optBoolean("disabled", false) ||
5678
- props.optJSONObject("accessibilityState")?.optBoolean("disabled", false) == true
5692
+ val longPressDisabled = craftNativeBoolean(props.opt("disabled")) ||
5693
+ craftNativeBoolean(props.optJSONObject("accessibilityState")?.opt("disabled"))
5679
5694
  val longPressHandler = craftNativeEventName(events, "onLongPress").takeUnless { longPressDisabled } ?: ""
5680
5695
  if (longPressHandler.isNotBlank()) {
5681
5696
  screen.longPressHandlers[control] = longPressHandler
@@ -5720,9 +5735,9 @@ class MainActivity : AppCompatActivity() {
5720
5735
  next[identity] = control
5721
5736
  applyViewStyle(control, style)
5722
5737
  if (type == "ActivityIndicator") {
5723
- val animating = props.optBoolean("animating", true)
5724
- val hidesWhenStopped = props.optBoolean("hidesWhenStopped", true)
5725
- control.visibility = if (style.optString("display") == "none" || (!animating && hidesWhenStopped)) {
5738
+ val animating = craftNativeBoolean(props.opt("animating"), true)
5739
+ val hidesWhenStopped = craftNativeBoolean(props.opt("hidesWhenStopped"), true)
5740
+ control.visibility = if (craftNativeOptionalString(style.opt("display")) == "none" || (!animating && hidesWhenStopped)) {
5726
5741
  View.GONE
5727
5742
  } else {
5728
5743
  View.VISIBLE
@@ -5843,7 +5858,7 @@ class MainActivity : AppCompatActivity() {
5843
5858
  layout.setChildStyle(childView, child.optJSONObject("style") ?: JSONObject(), resources.displayMetrics.density)
5844
5859
  }
5845
5860
  }
5846
- if (style.optString("flexDirection").endsWith("-reverse")) desired.reverse()
5861
+ if (craftNativeOptionalString(style.opt("flexDirection"))?.endsWith("-reverse") == true) desired.reverse()
5847
5862
  val desiredSet = desired.toSet()
5848
5863
  for ((index, childView) in desired.withIndex()) {
5849
5864
  if (layout.getChildAt(index) !== childView) {
@@ -5892,12 +5907,12 @@ class MainActivity : AppCompatActivity() {
5892
5907
  }
5893
5908
  val threshold = craftNativeNonNegativeDouble(props, "threshold", 0.1)
5894
5909
  val authoredThreshold = craftNativeNonNegativeDouble(props, "onEndReachedThreshold", threshold)
5895
- val horizontal = props.optBoolean("horizontal", false)
5910
+ val horizontal = craftNativeBoolean(props.opt("horizontal"))
5896
5911
  list.apply(
5897
5912
  nodes = nodes,
5898
5913
  horizontal = horizontal,
5899
- columns = props.optInt("numColumns", 1),
5900
- inverted = props.optBoolean("inverted", false),
5914
+ columns = craftNativeInt(props.opt("numColumns"), 1),
5915
+ inverted = craftNativeBoolean(props.opt("inverted")),
5901
5916
  endReachedThreshold = authoredThreshold,
5902
5917
  renderItem = { node, itemIdentity, _ ->
5903
5918
  val rows = screen.flatListRows.getOrPut(list) { mutableMapOf() }
@@ -5917,16 +5932,16 @@ class MainActivity : AppCompatActivity() {
5917
5932
  },
5918
5933
  )
5919
5934
  list.setScrollIndicators(
5920
- props.optBoolean("showsVerticalScrollIndicator", true),
5921
- props.optBoolean("showsHorizontalScrollIndicator", true),
5935
+ craftNativeBoolean(props.opt("showsVerticalScrollIndicator"), true),
5936
+ craftNativeBoolean(props.opt("showsHorizontalScrollIndicator"), true),
5922
5937
  )
5923
- list.setScrollEnabled(props.optBoolean("scrollEnabled", true))
5924
- list.setPagingEnabled(props.optBoolean("pagingEnabled", false))
5938
+ list.setScrollEnabled(craftNativeBoolean(props.opt("scrollEnabled"), true))
5939
+ list.setPagingEnabled(craftNativeBoolean(props.opt("pagingEnabled")))
5925
5940
  list.setScrollTarget(props.opt("scrollTarget"))
5926
5941
  list.setBounceOptions(
5927
- props.optBoolean("bounces", true),
5928
- props.optBoolean("alwaysBounceVertical", !horizontal),
5929
- props.optBoolean("alwaysBounceHorizontal", horizontal),
5942
+ craftNativeBoolean(props.opt("bounces"), true),
5943
+ craftNativeBoolean(props.opt("alwaysBounceVertical"), !horizontal),
5944
+ craftNativeBoolean(props.opt("alwaysBounceHorizontal"), horizontal),
5930
5945
  )
5931
5946
  }
5932
5947
 
@@ -5970,7 +5985,7 @@ class MainActivity : AppCompatActivity() {
5970
5985
  }
5971
5986
  applyTransform(view, style.optJSONArray("transform"))
5972
5987
  if (view !is CraftNativeModalView) {
5973
- view.visibility = if (style.optString("display") == "none") View.GONE else View.VISIBLE
5988
+ view.visibility = if (craftNativeOptionalString(style.opt("display")) == "none") View.GONE else View.VISIBLE
5974
5989
  }
5975
5990
  view.elevation = dp(craftNativeNonNegativeDouble(style, "elevation", 0.0)).toFloat()
5976
5991
  view.z = craftNativeFiniteFloat(style, "zIndex", 0f)
@@ -5979,13 +5994,13 @@ class MainActivity : AppCompatActivity() {
5979
5994
  // background paints over the decoded picture (only rounded corners
5980
5995
  // showed it). Keep the outline/borders, with a transparent fill.
5981
5996
  val backgroundColor = if (view is CraftNativeVideoView) Color.TRANSPARENT
5982
- else color(style.optString("backgroundColor"), Color.TRANSPARENT)
5997
+ else color(craftNativeOptionalString(style.opt("backgroundColor")).orEmpty(), Color.TRANSPARENT)
5983
5998
  val border = craftNativeNonNegativeDouble(style, "borderWidth", 0.0)
5984
5999
  val radius = craftNativeNonNegativeDouble(style, "borderRadius", 0.0)
5985
- val gradientDirection = style.optString("gradientDirection").takeIf { it.isNotBlank() }
5986
- val gradientFrom = style.optString("gradientFrom").takeIf { it.isNotBlank() }
5987
- val gradientVia = style.optString("gradientVia").takeIf { it.isNotBlank() }
5988
- val gradientTo = style.optString("gradientTo").takeIf { it.isNotBlank() }
6000
+ val gradientDirection = craftNativeOptionalString(style.opt("gradientDirection"))
6001
+ val gradientFrom = craftNativeOptionalString(style.opt("gradientFrom"))
6002
+ val gradientVia = craftNativeOptionalString(style.opt("gradientVia"))
6003
+ val gradientTo = craftNativeOptionalString(style.opt("gradientTo"))
5989
6004
  val gradientColors = if (gradientDirection != null && gradientFrom != null && gradientTo != null) {
5990
6005
  listOfNotNull(
5991
6006
  color(gradientFrom, Color.TRANSPARENT),
@@ -6003,7 +6018,7 @@ class MainActivity : AppCompatActivity() {
6003
6018
  listOf(radius, radius)
6004
6019
  }.toFloatArray()
6005
6020
  val sideColors = listOf("borderTopColor", "borderRightColor", "borderBottomColor", "borderLeftColor")
6006
- .map { color(style.optString(it).ifBlank { style.optString("borderColor") }, Color.TRANSPARENT) }
6021
+ .map { color(craftNativeOptionalString(style.opt(it)) ?: craftNativeOptionalString(style.opt("borderColor")).orEmpty(), Color.TRANSPARENT) }
6007
6022
  .toIntArray()
6008
6023
  val defaultBackground = if (defaultBackgrounds.containsKey(view)) {
6009
6024
  defaultBackgrounds[view]
@@ -6017,7 +6032,7 @@ class MainActivity : AppCompatActivity() {
6017
6032
  cornerRadii,
6018
6033
  sideWidths,
6019
6034
  sideColors,
6020
- style.optString("borderStyle").takeIf { it == "dashed" || it == "dotted" },
6035
+ craftNativeOptionalString(style.opt("borderStyle"))?.takeIf { it == "dashed" || it == "dotted" },
6021
6036
  gradientDirection,
6022
6037
  gradientColors,
6023
6038
  )
@@ -6029,8 +6044,8 @@ class MainActivity : AppCompatActivity() {
6029
6044
  } else setColor(backgroundColor)
6030
6045
  if (hasCornerRadii) setCornerRadii(cornerRadii) else cornerRadius = dp(radius).toFloat()
6031
6046
  if (border > 0) {
6032
- val borderColor = color(style.optString("borderColor"), Color.TRANSPARENT)
6033
- when (style.optString("borderStyle")) {
6047
+ val borderColor = color(craftNativeOptionalString(style.opt("borderColor")).orEmpty(), Color.TRANSPARENT)
6048
+ when (craftNativeOptionalString(style.opt("borderStyle"))) {
6034
6049
  "dashed" -> setStroke(dp(border), borderColor, dp(border * 3).toFloat(), dp(border * 2).toFloat())
6035
6050
  "dotted" -> setStroke(dp(border), borderColor, dp(border).toFloat(), dp(border * 1.5).toFloat())
6036
6051
  else -> setStroke(dp(border), borderColor)
@@ -6043,13 +6058,14 @@ class MainActivity : AppCompatActivity() {
6043
6058
  // and the web.
6044
6059
  null
6045
6060
  } else defaultBackground
6046
- view.clipToOutline = style.optString("overflow") == "hidden"
6047
- if (view is ViewGroup) view.clipChildren = style.optString("overflow") == "hidden"
6061
+ val overflow = craftNativeOptionalString(style.opt("overflow"))
6062
+ view.clipToOutline = overflow == "hidden"
6063
+ if (view is ViewGroup) view.clipChildren = overflow == "hidden"
6048
6064
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P &&
6049
6065
  (style.has("shadowColor") || style.has("shadowOpacity") || style.has("shadowRadius"))
6050
6066
  ) {
6051
6067
  val shadowOpacity = craftNativeFiniteFloat(style, "shadowOpacity", 1f).coerceIn(0f, 1f)
6052
- val shadow = color(style.optString("shadowColor"), Color.BLACK)
6068
+ val shadow = color(craftNativeOptionalString(style.opt("shadowColor")).orEmpty(), Color.BLACK)
6053
6069
  val alphaShadow = Color.argb((Color.alpha(shadow) * shadowOpacity).roundToInt(), Color.red(shadow), Color.green(shadow), Color.blue(shadow))
6054
6070
  view.outlineAmbientShadowColor = alphaShadow
6055
6071
  view.outlineSpotShadowColor = alphaShadow
@@ -6147,7 +6163,7 @@ class MainActivity : AppCompatActivity() {
6147
6163
  }
6148
6164
 
6149
6165
  private fun configureText(view: TextView, value: String, style: JSONObject) {
6150
- view.text = when (style.optString("textTransform")) {
6166
+ view.text = when (craftNativeOptionalString(style.opt("textTransform"))) {
6151
6167
  "uppercase" -> value.uppercase()
6152
6168
  "lowercase" -> value.lowercase()
6153
6169
  "capitalize" -> value.split(' ').joinToString(" ") { word -> word.replaceFirstChar(Char::uppercase) }
@@ -6191,11 +6207,22 @@ class MainActivity : AppCompatActivity() {
6191
6207
  )
6192
6208
  view.vector = vector
6193
6209
  view.text = if (vector != null) "" else androidIconGlyph(symbol)
6194
- val weight = when (style.optString("fontWeight")) {
6195
- "bold", "700", "800", "900" -> Typeface.BOLD
6196
- else -> Typeface.NORMAL
6210
+ val weight = when (craftNativeFontWeight(style.opt("fontWeight"))) {
6211
+ "100" -> 100
6212
+ "200" -> 200
6213
+ "300" -> 300
6214
+ "500" -> 500
6215
+ "600" -> 600
6216
+ "bold", "700" -> 700
6217
+ "800" -> 800
6218
+ "900" -> 900
6219
+ else -> 400
6220
+ }
6221
+ view.typeface = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
6222
+ Typeface.create("sans-serif", weight, false)
6223
+ } else {
6224
+ Typeface.create("sans-serif", if (weight >= 600) Typeface.BOLD else Typeface.NORMAL)
6197
6225
  }
6198
- view.typeface = Typeface.create("sans-serif", weight)
6199
6226
  val hasExplicitWidth = style.has("width") && !style.isNull("width")
6200
6227
  val hasExplicitHeight = style.has("height") && !style.isNull("height")
6201
6228
  if (!hasExplicitWidth && !hasExplicitHeight) {
@@ -6246,20 +6273,22 @@ class MainActivity : AppCompatActivity() {
6246
6273
  }
6247
6274
 
6248
6275
  private fun configureTextStyle(view: TextView, style: JSONObject) {
6249
- view.setTextColor(color(style.optString("color"), defaultTextColor))
6276
+ view.setTextColor(color(craftNativeOptionalString(style.opt("color")).orEmpty(), defaultTextColor))
6250
6277
  view.setTextSize(TypedValue.COMPLEX_UNIT_SP, craftNativeFiniteFloat(style, "fontSize", 16f))
6251
- val weight = when (style.optString("fontWeight")) {
6278
+ val fontWeight = style.opt("fontWeight") as? String
6279
+ val weight = when (fontWeight) {
6252
6280
  "bold" -> 700
6253
6281
  "100", "200", "300", "400", "500", "600", "700", "800", "900" -> style.optInt("fontWeight")
6254
6282
  else -> 400
6255
6283
  }
6256
- val italic = style.optString("fontStyle") == "italic"
6257
- val family = style.optString("fontFamily").takeIf { it.isNotBlank() }
6284
+ val italic = craftNativeOptionalString(style.opt("fontStyle")) == "italic"
6285
+ val family = craftNativeOptionalString(style.opt("fontFamily"))?.takeIf { it.isNotBlank() }
6258
6286
  view.typeface = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
6259
6287
  Typeface.create(Typeface.create(family, Typeface.NORMAL), weight, italic)
6260
6288
  } else Typeface.create(family, (if (weight >= 600) Typeface.BOLD else Typeface.NORMAL) or
6261
6289
  (if (italic) Typeface.ITALIC else Typeface.NORMAL))
6262
- view.textAlignment = when (style.optString("textAlign")) {
6290
+ val textAlign = craftNativeOptionalString(style.opt("textAlign"))
6291
+ view.textAlignment = when (textAlign) {
6263
6292
  "left" -> View.TEXT_ALIGNMENT_TEXT_START
6264
6293
  "right" -> View.TEXT_ALIGNMENT_TEXT_END
6265
6294
  "center" -> View.TEXT_ALIGNMENT_CENTER
@@ -6267,21 +6296,21 @@ class MainActivity : AppCompatActivity() {
6267
6296
  else -> View.TEXT_ALIGNMENT_INHERIT
6268
6297
  }
6269
6298
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
6270
- view.justificationMode = if (style.optString("textAlign") == "justify") {
6299
+ view.justificationMode = if (textAlign == "justify") {
6271
6300
  Layout.JUSTIFICATION_MODE_INTER_WORD
6272
6301
  } else {
6273
6302
  Layout.JUSTIFICATION_MODE_NONE
6274
6303
  }
6275
6304
  }
6276
- view.letterSpacing = style.optDouble("letterSpacing", Double.NaN)
6305
+ view.letterSpacing = craftNativeFiniteDouble(style, "letterSpacing", Double.NaN)
6277
6306
  .takeIf { it.isFinite() }
6278
6307
  ?.let { if (view.textSize > 0) dp(it).toFloat() / view.textSize else 0f }
6279
6308
  ?: 0f
6280
6309
  view.setLineSpacing(
6281
- style.optDouble("lineHeight", Double.NaN).takeIf { it.isFinite() }?.let { dp(it) - view.textSize } ?: 0f,
6310
+ craftNativeFiniteDouble(style, "lineHeight", Double.NaN).takeIf { it.isFinite() }?.let { dp(it) - view.textSize } ?: 0f,
6282
6311
  1f
6283
6312
  )
6284
- val shadowColor = style.optString("textShadowColor").takeIf { it.isNotBlank() }
6313
+ val shadowColor = craftNativeOptionalString(style.opt("textShadowColor"))?.takeIf { it.isNotBlank() }
6285
6314
  if (shadowColor == null) {
6286
6315
  view.paint.clearShadowLayer()
6287
6316
  } else {
@@ -6298,16 +6327,16 @@ class MainActivity : AppCompatActivity() {
6298
6327
  }
6299
6328
 
6300
6329
  private fun applyTextDecoration(view: TextView, style: JSONObject) {
6301
- val line = style.optString("textDecorationLine")
6330
+ val line = craftNativeOptionalString(style.opt("textDecorationLine")).orEmpty()
6302
6331
  if (view is EditText) {
6303
6332
  // A field's own text, spanned in place: replacing it would move
6304
6333
  // the caret to the start and tell the screen the text changed.
6305
6334
  val editable = view.text ?: return
6306
6335
  editable.getSpans(0, editable.length, CraftTextDecorationSpan::class.java).forEach(editable::removeSpan)
6307
6336
  if (line.isBlank() || line == "none" || editable.isEmpty()) return
6308
- val fieldColor = style.optString("textDecorationColor").takeIf { it.isNotBlank() }?.let { color(it, Color.TRANSPARENT) }
6337
+ val fieldColor = craftNativeOptionalString(style.opt("textDecorationColor"))?.takeIf { it.isNotBlank() }?.let { color(it, Color.TRANSPARENT) }
6309
6338
  editable.setSpan(
6310
- CraftTextDecorationSpan(line, style.optString("textDecorationStyle").ifBlank { "solid" }, fieldColor),
6339
+ CraftTextDecorationSpan(line, craftNativeOptionalString(style.opt("textDecorationStyle")).orEmpty().ifBlank { "solid" }, fieldColor),
6311
6340
  0,
6312
6341
  editable.length,
6313
6342
  Spannable.SPAN_INCLUSIVE_INCLUSIVE,
@@ -6319,14 +6348,14 @@ class MainActivity : AppCompatActivity() {
6319
6348
  if (view.text is Spannable) view.text = text
6320
6349
  return
6321
6350
  }
6322
- val decorationColor = style.optString("textDecorationColor")
6351
+ val decorationColor = craftNativeOptionalString(style.opt("textDecorationColor")).orEmpty()
6323
6352
  .takeIf { it.isNotBlank() }
6324
6353
  ?.let { color(it, Color.TRANSPARENT) }
6325
6354
  val decorated = SpannableString(text)
6326
6355
  decorated.setSpan(
6327
6356
  CraftTextDecorationSpan(
6328
6357
  line,
6329
- style.optString("textDecorationStyle").ifBlank { "solid" },
6358
+ craftNativeOptionalString(style.opt("textDecorationStyle")).orEmpty().ifBlank { "solid" },
6330
6359
  decorationColor,
6331
6360
  ),
6332
6361
  0,
@@ -6510,7 +6539,7 @@ class MainActivity : AppCompatActivity() {
6510
6539
  if (type == "Icon") "image" else type.lowercase()
6511
6540
  }
6512
6541
  val state = props.optJSONObject("accessibilityState")
6513
- val disabled = props.optBoolean("disabled", false) || state?.optBoolean("disabled", false) == true
6542
+ val disabled = craftNativeBoolean(props.opt("disabled")) || craftNativeBoolean(state?.opt("disabled"))
6514
6543
  if (type !in setOf("Button", "Link", "Checkbox", "TextInput", "Picker", "DatePicker", "Modal", "Switch", "Slider", "ActivityIndicator")) {
6515
6544
  view.isEnabled = !disabled
6516
6545
  } else if (disabled) {
@@ -6534,8 +6563,16 @@ class MainActivity : AppCompatActivity() {
6534
6563
  info.roleDescription = role.takeIf { it !in setOf("view", "text", "textinput", "scrollview") }
6535
6564
  info.hintText = host.tooltipText
6536
6565
  info.isEnabled = host.isEnabled && !disabled
6537
- info.isSelected = state?.optBoolean("selected", false) == true
6538
- info.isChecked = state?.optBoolean("checked", false) == true
6566
+ // Keep the native widget state when no accessibilityState was
6567
+ // authored. Overwriting it with the strict false fallback
6568
+ // made a checked Switch/CheckBox announce as unchecked unless
6569
+ // the screen redundantly mirrored its value into metadata.
6570
+ if (state?.has("selected") == true) {
6571
+ info.isSelected = craftNativeBoolean(state.opt("selected"))
6572
+ }
6573
+ if (state?.has("checked") == true) {
6574
+ info.isChecked = craftNativeBoolean(state.opt("checked"))
6575
+ }
6539
6576
  }
6540
6577
  })
6541
6578
  ViewCompat.setImportantForAccessibility(
@@ -6551,11 +6588,11 @@ class MainActivity : AppCompatActivity() {
6551
6588
  private fun color(raw: String, fallback: Int) = runCatching { craftNativeColor(raw) }.getOrDefault(fallback)
6552
6589
 
6553
6590
  private fun inputTypeFor(props: JSONObject): Int {
6554
- val base = if (props.optBoolean("multiline", false)) InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_MULTI_LINE
6591
+ val base = if (craftNativeBoolean(props.opt("multiline"))) InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_MULTI_LINE
6555
6592
  else InputType.TYPE_CLASS_TEXT
6556
6593
  // Text variations do not carry their input class or multiline flag.
6557
6594
  // Preserve both when selecting an email, URL, or search keyboard.
6558
- val keyboard = when (props.optString("keyboardType")) {
6595
+ val keyboard = when (props.opt("keyboardType") as? String) {
6559
6596
  "email-address" -> base or InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS
6560
6597
  "url" -> base or InputType.TYPE_TEXT_VARIATION_URI
6561
6598
  "numeric", "number-pad" -> InputType.TYPE_CLASS_NUMBER
@@ -6565,21 +6602,22 @@ class MainActivity : AppCompatActivity() {
6565
6602
  "visible-password" -> base or InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD
6566
6603
  else -> base
6567
6604
  }
6568
- val capitalization = when (props.optString("autoCapitalize")) {
6605
+ val capitalization = when (props.opt("autoCapitalize") as? String) {
6569
6606
  "characters" -> InputType.TYPE_TEXT_FLAG_CAP_CHARACTERS
6570
6607
  "words" -> InputType.TYPE_TEXT_FLAG_CAP_WORDS
6571
6608
  "sentences" -> InputType.TYPE_TEXT_FLAG_CAP_SENTENCES
6572
6609
  else -> 0
6573
6610
  }
6574
- val correction = if (props.optBoolean("autoCorrect", true)) InputType.TYPE_TEXT_FLAG_AUTO_CORRECT else 0
6575
- val secure = if (props.optBoolean("secureTextEntry", false)) InputType.TYPE_TEXT_VARIATION_PASSWORD else 0
6611
+ val correction = if (craftNativeBoolean(props.opt("autoCorrect"), true)) InputType.TYPE_TEXT_FLAG_AUTO_CORRECT else 0
6612
+ val secure = if (craftNativeBoolean(props.opt("secureTextEntry"))) InputType.TYPE_TEXT_VARIATION_PASSWORD else 0
6576
6613
  return keyboard or capitalization or correction or secure
6577
6614
  }
6578
6615
 
6579
6616
  /// The autofill hint for `textContentType` (iOS's names) or `autoComplete`
6580
6617
  /// (HTML's), so a password manager fills the field.
6581
6618
  private fun autofillHintFor(props: JSONObject): String? {
6582
- val raw = props.optString("textContentType").ifBlank { props.optString("autoComplete") }
6619
+ val raw = (props.opt("textContentType") as? String)?.takeIf { it.isNotBlank() }
6620
+ ?: (props.opt("autoComplete") as? String).orEmpty()
6583
6621
  return when (raw.replace("-", "").replace("_", "").lowercase()) {
6584
6622
  "username" -> View.AUTOFILL_HINT_USERNAME
6585
6623
  "nickname" -> "personNickname"
@@ -6610,7 +6648,7 @@ class MainActivity : AppCompatActivity() {
6610
6648
  }
6611
6649
  }
6612
6650
 
6613
- private fun imeOptionsFor(props: JSONObject): Int = when (props.optString("returnKeyType")) {
6651
+ private fun imeOptionsFor(props: JSONObject): Int = when (props.opt("returnKeyType") as? String) {
6614
6652
  "done" -> EditorInfo.IME_ACTION_DONE
6615
6653
  "go" -> EditorInfo.IME_ACTION_GO
6616
6654
  "next" -> EditorInfo.IME_ACTION_NEXT