@craft-native/android 0.0.205 → 0.0.207

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.205",
3
+ "version": "0.0.207",
4
4
  "homepage": "https://github.com/craft-native/craft/tree/main/packages/android#readme",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,6 +1,7 @@
1
1
  package {{PACKAGE_NAME}}
2
2
 
3
3
  import android.content.Context
4
+ import org.json.JSONObject
4
5
  import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
5
6
 
6
7
  /** Swipe-to-refresh host for a native FlatList. */
@@ -21,6 +22,12 @@ internal class CraftNativeRefreshableFlatList(context: Context) : SwipeRefreshLa
21
22
  if (handler == null) isRefreshing = false
22
23
  }
23
24
 
25
+ fun setRefreshAppearance(props: JSONObject, fallback: Int) {
26
+ val tint = props.optString("tintColor").takeIf { it.isNotBlank() }
27
+ val resolved = tint?.let { runCatching { craftNativeColor(it) }.getOrNull() } ?: fallback
28
+ setColorSchemeColors(resolved)
29
+ }
30
+
24
31
  override fun onDetachedFromWindow() {
25
32
  refreshHandler = null
26
33
  isRefreshing = false
@@ -148,28 +148,48 @@ 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) {
156
+ is String -> value.takeIf { it.isNotEmpty() }
152
157
  is Number -> value.toDouble().takeIf { it.isFinite() }?.toString()
153
- else -> craftNativeOptionalString(value)
158
+ else -> null
154
159
  }
155
160
 
156
161
  private fun craftNativeEventName(events: JSONObject, vararg names: String): String =
157
162
  names.firstNotNullOfOrNull { name -> craftNativeOptionalString(events.opt(name)) }.orEmpty()
158
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
+
159
174
  internal fun craftNativePadding(style: JSONObject?, name: String, fallback: Int, density: Float): Int {
160
- val value = style?.optDouble(name, Double.NaN) ?: Double.NaN
175
+ val value = craftNativeNumber(style?.opt(name)) ?: Double.NaN
161
176
  val pixels = value * density
162
177
  return if (value.isFinite() && value >= 0 && pixels.isFinite()) pixels.toInt() else fallback
163
178
  }
164
179
 
165
180
  internal fun craftNativeFiniteFloat(style: JSONObject?, name: String, fallback: Float): Float {
166
- val value = style?.optDouble(name, Double.NaN) ?: Double.NaN
181
+ val value = craftNativeNumber(style?.opt(name)) ?: Double.NaN
167
182
  val narrowed = value.toFloat()
168
183
  return if (value.isFinite() && narrowed.isFinite()) narrowed else fallback
169
184
  }
170
185
 
171
186
  internal fun craftNativeFiniteDouble(style: JSONObject?, name: String, fallback: Double): Double {
172
- 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
173
193
  return value.takeIf { it.isFinite() } ?: fallback
174
194
  }
175
195
 
@@ -220,16 +240,16 @@ private data class CraftNativeLayoutStyle(
220
240
  companion object {
221
241
  fun from(raw: JSONObject, density: Float): CraftNativeLayoutStyle {
222
242
  fun length(name: String): CraftNativeLength? = CraftNativeLength.from(raw.opt(name), density)
223
- fun number(name: String): Int? {
243
+ fun numeric(name: String): Double? {
224
244
  if (!raw.has(name) || raw.isNull(name)) return null
225
- 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 ->
226
249
  (points * density).takeIf { it.isFinite() }?.roundToInt()
227
250
  }
228
251
  }
229
- fun decimal(name: String): Double? {
230
- if (!raw.has(name) || raw.isNull(name)) return null
231
- return raw.optDouble(name, Double.NaN).takeIf { it.isFinite() }
232
- }
252
+ fun decimal(name: String): Double? = numeric(name)
233
253
  val flex = decimal("flex")
234
254
  return CraftNativeLayoutStyle(
235
255
  width = length("width"), height = length("height"),
@@ -352,6 +372,14 @@ private class CraftNativeSvgView(context: Context) : View(context) {
352
372
  val vectorEffect: String? = null,
353
373
  val dominantBaseline: String? = null,
354
374
  ) {
375
+ private fun svgTypeface(weight: String?, inherited: Typeface): Typeface = when (weight?.lowercase()) {
376
+ "500" -> Typeface.create("sans-serif-medium", Typeface.NORMAL)
377
+ "600" -> Typeface.create("sans-serif", Typeface.BOLD)
378
+ "bold", "700", "800", "900" -> Typeface.DEFAULT_BOLD
379
+ "normal", "100", "200", "300", "400" -> Typeface.DEFAULT
380
+ else -> inherited
381
+ }
382
+
355
383
  fun merge(node: JSONObject): SvgPaint {
356
384
  val props = node.optJSONObject("props") ?: JSONObject()
357
385
  val style = node.optJSONObject("style") ?: JSONObject()
@@ -368,7 +396,7 @@ private class CraftNativeSvgView(context: Context) : View(context) {
368
396
  fun dashes(rawValue: Any?): List<Float>? {
369
397
  val values = rawValue as? JSONArray ?: return null
370
398
  return (0 until values.length()).mapNotNull {
371
- values.optDouble(it, Double.NaN).toFloat().takeIf { value -> value.isFinite() && value >= 0 }
399
+ craftNativeNumber(values.opt(it))?.toFloat()?.takeIf { value -> value.isFinite() && value >= 0 }
372
400
  }
373
401
  }
374
402
  // Keep the cascade explicit: computed style wins over a raw prop
@@ -387,7 +415,7 @@ private class CraftNativeSvgView(context: Context) : View(context) {
387
415
  strokeDashoffset = number("strokeDashoffset") ?: strokeDashoffset,
388
416
  strokeMiterlimit = number("strokeMiterlimit")?.takeIf { it > 0 } ?: strokeMiterlimit,
389
417
  fillRule = value("fillRule") ?: fillRule,
390
- typeface = if (weight in setOf("bold", "700")) Typeface.DEFAULT_BOLD else typeface,
418
+ typeface = svgTypeface(weight, typeface),
391
419
  strokeLinecap = value("strokeLinecap") ?: strokeLinecap,
392
420
  strokeLinejoin = value("strokeLinejoin") ?: strokeLinejoin,
393
421
  strokeDasharray = dashes(raw("strokeDasharray")) ?: strokeDasharray,
@@ -446,10 +474,10 @@ private class CraftNativeSvgView(context: Context) : View(context) {
446
474
  private fun viewBox(): Quadruple<Float, Float, Float, Float>? {
447
475
  val values = drawing.optJSONObject("props")?.optJSONArray("viewBox") ?: return null
448
476
  if (values.length() != 4) return null
449
- val x = values.optDouble(0, Double.NaN).toFloat()
450
- val y = values.optDouble(1, Double.NaN).toFloat()
451
- val width = values.optDouble(2, Double.NaN).toFloat()
452
- val height = values.optDouble(3, Double.NaN).toFloat()
477
+ val x = craftNativeNumber(values.opt(0))?.toFloat() ?: Float.NaN
478
+ val y = craftNativeNumber(values.opt(1))?.toFloat() ?: Float.NaN
479
+ val width = craftNativeNumber(values.opt(2))?.toFloat() ?: Float.NaN
480
+ val height = craftNativeNumber(values.opt(3))?.toFloat() ?: Float.NaN
453
481
  if (!x.isFinite() || !y.isFinite() || !width.isFinite() || !height.isFinite() || width <= 0f || height <= 0f) return null
454
482
  return Quadruple(width, height, x, y)
455
483
  }
@@ -471,12 +499,12 @@ private class CraftNativeSvgView(context: Context) : View(context) {
471
499
  private fun drawPath(canvas: Canvas, node: JSONObject, style: SvgPaint) {
472
500
  val props = node.optJSONObject("props") ?: JSONObject()
473
501
  val geometry = props.optJSONArray("geometry") ?: return
474
- if ((0 until geometry.length()).any { !geometry.optDouble(it, Double.NaN).toFloat().isFinite() }) return
502
+ if ((0 until geometry.length()).any { craftNativeNumber(geometry.opt(it))?.toFloat()?.isFinite() != true }) return
475
503
  val path = Path()
476
504
  var index = 0
477
505
  while (index < geometry.length()) {
478
- val command = geometry.optDouble(index, Double.NaN).toInt()
479
- fun number(offset: Int): Float = geometry.optDouble(index + offset, Double.NaN).toFloat()
506
+ val command = craftNativeNumber(geometry.opt(index))?.toInt() ?: break
507
+ fun number(offset: Int): Float = craftNativeNumber(geometry.opt(index + offset))?.toFloat() ?: Float.NaN
480
508
  when (command) {
481
509
  0 -> if (index + 2 < geometry.length()) { path.moveTo(number(1), number(2)); index += 3 } else break
482
510
  1 -> if (index + 2 < geometry.length()) { path.lineTo(number(1), number(2)); index += 3 } else break
@@ -542,9 +570,9 @@ private class CraftNativeSvgView(context: Context) : View(context) {
542
570
  for (index in 0 until children.length()) if (children.opt(index) is String) append(children.optString(index))
543
571
  }
544
572
  if (text.isEmpty()) return
545
- val size = (if (props.has("fontSize")) props.optDouble("fontSize", 12.0) else nodeStyle.optDouble("fontSize", 12.0))
546
- .takeIf { it.isFinite() }
573
+ val size = (if (props.has("fontSize")) craftNativeNumber(props.opt("fontSize")) else craftNativeNumber(nodeStyle.opt("fontSize")))
547
574
  ?.toFloat()
575
+ ?.takeIf { it.isFinite() }
548
576
  ?: 12f
549
577
  val paint = android.graphics.Paint(Paint.ANTI_ALIAS_FLAG).apply {
550
578
  color = resolveColor(style.fill, style.color, Color.BLACK)
@@ -552,8 +580,8 @@ private class CraftNativeSvgView(context: Context) : View(context) {
552
580
  textSize = size
553
581
  typeface = style.typeface
554
582
  }
555
- var x = props.optDouble("x", 0.0).takeIf { it.isFinite() }?.toFloat() ?: 0f
556
- val y = props.optDouble("y", 0.0).takeIf { it.isFinite() }?.toFloat() ?: 0f
583
+ var x = craftNativeNumber(props.opt("x"))?.toFloat()?.takeIf { it.isFinite() } ?: 0f
584
+ val y = craftNativeNumber(props.opt("y"))?.toFloat()?.takeIf { it.isFinite() } ?: 0f
557
585
  when (props.optString("textAnchor")) {
558
586
  "middle" -> x -= paint.measureText(text) / 2f
559
587
  "end" -> x -= paint.measureText(text)
@@ -620,16 +648,16 @@ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMap
620
648
 
621
649
  private fun applyPending() {
622
650
  val map = googleMap ?: return
623
- map.uiSettings.isScrollGesturesEnabled = pendingProps.optBoolean("scrollEnabled", true)
624
- map.uiSettings.isZoomGesturesEnabled = pendingProps.optBoolean("zoomEnabled", true)
625
- map.uiSettings.isRotateGesturesEnabled = pendingProps.optBoolean("rotateEnabled", false)
651
+ map.uiSettings.isScrollGesturesEnabled = craftNativeBoolean(pendingProps.opt("scrollEnabled"), true)
652
+ map.uiSettings.isZoomGesturesEnabled = craftNativeBoolean(pendingProps.opt("zoomEnabled"), true)
653
+ map.uiSettings.isRotateGesturesEnabled = craftNativeBoolean(pendingProps.opt("rotateEnabled"))
626
654
  map.mapType = when (craftNativeOptionalString(pendingProps.opt("mapType")).orEmpty().lowercase()) {
627
655
  "satellite" -> GoogleMap.MAP_TYPE_SATELLITE
628
656
  "hybrid" -> GoogleMap.MAP_TYPE_HYBRID
629
657
  "terrain" -> GoogleMap.MAP_TYPE_TERRAIN
630
658
  else -> GoogleMap.MAP_TYPE_NORMAL
631
659
  }
632
- val showUserLocation = pendingProps.optBoolean("showsUserLocation", false) && hasLocationPermission()
660
+ val showUserLocation = craftNativeBoolean(pendingProps.opt("showsUserLocation")) && hasLocationPermission()
633
661
  runCatching { map.isMyLocationEnabled = showUserLocation }
634
662
  val nextMarkers = mutableMapOf<String, Pair<LatLng, String?>>()
635
663
  val nextHandlers = mutableMapOf<String, String>()
@@ -645,7 +673,7 @@ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMap
645
673
  val points = coordinates(props.optJSONArray("geometry"))
646
674
  if (type == "mapmarker" || type == "marker") {
647
675
  points.firstOrNull()?.let { point ->
648
- nextMarkers[id] = point to craftNativeOptionalString(props.opt("title"))
676
+ nextMarkers[id] = point to (props.opt("title") as? String)
649
677
  listOf(events.opt("onPress"), events.opt("onMarkerPress"))
650
678
  .firstNotNullOfOrNull(::craftNativeOptionalString)
651
679
  ?.let { nextHandlers[id] = it }
@@ -708,7 +736,7 @@ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMap
708
736
  moveCamera(initialRegion)
709
737
  initialCameraKey = key
710
738
  }
711
- } else if (pendingProps.optBoolean("fitToBounds", false)) {
739
+ } else if (craftNativeBoolean(pendingProps.opt("fitToBounds"))) {
712
740
  val key = "fit:${overlayKey(nextMarkers, nextPolylines)}"
713
741
  if (initialCameraKey != key) {
714
742
  if (fitToBounds()) initialCameraKey = key
@@ -722,7 +750,7 @@ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMap
722
750
  super.onSizeChanged(width, height, oldWidth, oldHeight)
723
751
  // MapView can become ready before its host has a usable viewport. Do
724
752
  // not mark a fit as complete until the camera update can be issued.
725
- if (width > 0 && height > 0 && pendingProps.optBoolean("fitToBounds", false) && initialCameraKey == null) {
753
+ if (width > 0 && height > 0 && craftNativeBoolean(pendingProps.opt("fitToBounds")) && initialCameraKey == null) {
726
754
  applyPending()
727
755
  }
728
756
  }
@@ -1008,7 +1036,7 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
1008
1036
  alignContent = style.optString("alignContent", "flex-start")
1009
1037
  justifyContent = style.optString("justifyContent", "flex-start")
1010
1038
  fun spacing(name: String, fallback: Float): Float {
1011
- val value = style.optDouble(name, Double.NaN)
1039
+ val value = craftNativeNumber(style.opt(name)) ?: Double.NaN
1012
1040
  val pixels = value * density
1013
1041
  val narrowed = pixels.toFloat()
1014
1042
  return if (value.isFinite() && value >= 0 && pixels.isFinite() && narrowed.isFinite()) narrowed else fallback
@@ -2209,13 +2237,13 @@ private class CraftNativeScrollView(context: Context) : SwipeRefreshLayout(conte
2209
2237
  }
2210
2238
  }
2211
2239
 
2212
- fun setRefreshAppearance(props: JSONObject, density: Float) {
2240
+ fun setRefreshAppearance(props: JSONObject, density: Float, fallback: Int) {
2213
2241
  val tint = craftNativeOptionalString(props.opt("tintColor"))
2214
- if (tint != null) setColorSchemeColors(craftNativeColor(tint))
2215
- else setColorSchemeColors(Color.WHITE)
2242
+ setColorSchemeColors(tint?.let { runCatching { craftNativeColor(it) }.getOrNull() } ?: fallback)
2216
2243
  val background = craftNativeOptionalString(props.opt("progressBackgroundColor"))
2217
- if (background != null) setProgressBackgroundColorSchemeColor(craftNativeColor(background))
2218
- else setProgressBackgroundColorSchemeColor(Color.TRANSPARENT)
2244
+ setProgressBackgroundColorSchemeColor(
2245
+ background?.let { runCatching { craftNativeColor(it) }.getOrNull() } ?: Color.TRANSPARENT,
2246
+ )
2219
2247
  val offset = props.optDouble("progressViewOffset", Double.NaN)
2220
2248
  val requested = if (offset.isFinite()) (offset * density).roundToInt() else null
2221
2249
  if (configuredProgressOffset != requested) {
@@ -2725,7 +2753,7 @@ private class CraftNativeDatePickerView(context: Context) : FrameLayout(context)
2725
2753
  }
2726
2754
 
2727
2755
  fun configure(props: JSONObject) {
2728
- val requestedMode = when (props.optString("mode")) {
2756
+ val requestedMode = when (props.opt("mode") as? String) {
2729
2757
  "time" -> PickerMode.TIME
2730
2758
  "datetime", "dateAndTime", "datetime-local" -> PickerMode.DATE_TIME
2731
2759
  "countdown" -> PickerMode.COUNTDOWN
@@ -2765,7 +2793,7 @@ private class CraftNativeDatePickerView(context: Context) : FrameLayout(context)
2765
2793
  }
2766
2794
 
2767
2795
  suppressChanges = true
2768
- minuteInterval = props.optInt("minuteInterval", 1).takeIf { it in 1..30 && 60 % it == 0 } ?: 1
2796
+ minuteInterval = craftNativeInt(props.opt("minuteInterval"), 1).takeIf { it in 1..30 && 60 % it == 0 } ?: 1
2769
2797
  val defaults = defaultDateBounds
2770
2798
  datePicker?.minDate = pickerDate(props.opt("minimumDate"))?.timeInMillis ?: defaults?.first ?: datePicker?.minDate ?: 0L
2771
2799
  datePicker?.maxDate = pickerDate(props.opt("maximumDate"))?.timeInMillis ?: defaults?.second ?: datePicker?.maxDate ?: Long.MAX_VALUE
@@ -2775,7 +2803,7 @@ private class CraftNativeDatePickerView(context: Context) : FrameLayout(context)
2775
2803
  } else {
2776
2804
  pickerDate(props.opt("value"))?.let { applyDate(it) }
2777
2805
  }
2778
- val enabled = !props.optBoolean("disabled", false)
2806
+ val enabled = !craftNativeBoolean(props.opt("disabled"))
2779
2807
  isEnabled = enabled
2780
2808
  datePicker?.isEnabled = enabled
2781
2809
  timePicker?.isEnabled = enabled
@@ -2862,7 +2890,8 @@ private class CraftNativeDatePickerView(context: Context) : FrameLayout(context)
2862
2890
  if (text.isBlank() || text == JSONObject.NULL.toString()) return null
2863
2891
  val formats = listOf(
2864
2892
  "HH:mm", "HH:mm:ss", "yyyy-MM-dd", "yyyy-MM-dd'T'HH:mm",
2865
- "yyyy-MM-dd'T'HH:mm:ss", "yyyy-MM-dd'T'HH:mm:ss.SSSXXX", "yyyy-MM-dd'T'HH:mm:ssX",
2893
+ "yyyy-MM-dd'T'HH:mm:ss", "yyyy-MM-dd'T'HH:mm:ss.SSS",
2894
+ "yyyy-MM-dd'T'HH:mm:ss.SSSXXX", "yyyy-MM-dd'T'HH:mm:ssX",
2866
2895
  )
2867
2896
  formats.firstNotNullOfOrNull { format ->
2868
2897
  runCatching {
@@ -4485,6 +4514,13 @@ class MainActivity : AppCompatActivity() {
4485
4514
  screen.view.setBackgroundColor(configured ?: ContextCompat.getColor(this, R.color.background))
4486
4515
  }
4487
4516
 
4517
+ /** Refresh controls use a contrasting system tint when no color is authored. */
4518
+ private fun refreshTintFallback(screen: Screen): Int {
4519
+ val background = (screen.view.background as? ColorDrawable)?.color
4520
+ ?: ContextCompat.getColor(this, R.color.background)
4521
+ return if (Color.luminance(background) < 0.5f) Color.WHITE else Color.BLACK
4522
+ }
4523
+
4488
4524
  /** The bridge's capability names: Craft's core set and this host's services. */
4489
4525
  private fun capabilityNames(): JSONArray = capabilities.names().apply {
4490
4526
  services.names().forEach { name -> put(name) }
@@ -4908,7 +4944,7 @@ class MainActivity : AppCompatActivity() {
4908
4944
  val control = when (type) {
4909
4945
  "Text" -> ((previous as? TextView)?.takeUnless { it is Button || it is EditText } ?: TextView(this)).apply {
4910
4946
  includeFontPadding = false
4911
- maxLines = props.optInt("numberOfLines", 0).takeIf { it > 0 } ?: Int.MAX_VALUE
4947
+ maxLines = craftNativeInt(props.opt("numberOfLines"), 0).takeIf { it > 0 } ?: Int.MAX_VALUE
4912
4948
  ellipsize = when (props.optString("ellipsizeMode")) {
4913
4949
  "head" -> TextUtils.TruncateAt.START
4914
4950
  "middle" -> TextUtils.TruncateAt.MIDDLE
@@ -4919,7 +4955,7 @@ class MainActivity : AppCompatActivity() {
4919
4955
  }
4920
4956
  "StatusBar" -> (previous as? CraftNativeFlexLayout ?: CraftNativeFlexLayout(this)).apply {
4921
4957
  screen.statusBarStyle = craftNativeOptionalString(props.opt("barStyle"))
4922
- screen.statusBarHidden = if (props.has("hidden") && !props.isNull("hidden")) props.optBoolean("hidden") else null
4958
+ screen.statusBarHidden = if (props.has("hidden") && !props.isNull("hidden")) craftNativeBoolean(props.opt("hidden")) else null
4923
4959
  visibility = View.GONE
4924
4960
  }
4925
4961
  "RefreshControl" -> (previous as? CraftNativeFlexLayout ?: CraftNativeFlexLayout(this)).apply {
@@ -4927,17 +4963,18 @@ class MainActivity : AppCompatActivity() {
4927
4963
  }
4928
4964
  "Link" -> ((previous as? TextView)?.takeUnless { it is Button } ?: TextView(this)).apply {
4929
4965
  includeFontPadding = false
4930
- configureText(this, craftNativeOptionalString(props.opt("title")) ?: childText(children), style)
4966
+ configureText(this, (props.opt("title") as? String) ?: childText(children), style)
4931
4967
  val linkColor = craftNativeOptionalString(props.opt("color")) ?: craftNativeOptionalString(style.opt("color"))
4932
4968
  setTextColor(color(linkColor.orEmpty(), Color.rgb(33, 150, 243)))
4933
4969
  paintFlags = paintFlags or Paint.UNDERLINE_TEXT_FLAG
4934
- isEnabled = !props.optBoolean("disabled", false)
4970
+ isEnabled = !craftNativeBoolean(props.opt("disabled"))
4935
4971
  isClickable = true
4936
4972
  isFocusable = true
4937
4973
  val target = listOf(props.opt("href"), props.opt("to"))
4938
- .firstNotNullOfOrNull(::craftNativeOptionalString)
4974
+ .mapNotNull { it as? String }
4975
+ .firstOrNull { it.trim().isNotEmpty() }
4939
4976
  ?.trim()
4940
- ?.takeIf { !props.optBoolean("disabled", false) }
4977
+ ?.takeIf { !craftNativeBoolean(props.opt("disabled")) }
4941
4978
  if (target != null) screen.linkTargets[this] = target else screen.linkTargets.remove(this)
4942
4979
  val handler = craftNativeEventName(events, "onPress", "onClick")
4943
4980
  if (handler.isNotBlank()) screen.handlers[this] = handler else screen.handlers.remove(this)
@@ -4958,11 +4995,13 @@ class MainActivity : AppCompatActivity() {
4958
4995
  minimumHeight = 0
4959
4996
  setPadding(0, 0, 0, 0)
4960
4997
  }).apply {
4961
- val title = craftNativeOptionalString(props.opt("title")) ?: childText(children)
4998
+ val title = (props.opt("title") as? String) ?: childText(children)
4962
4999
  isAllCaps = false
4963
5000
  configureText(this, title, style)
4964
- craftNativeOptionalString(props.opt("color"))?.let { setTextColor(color(it, defaultTextColor)) }
4965
- isEnabled = !props.optBoolean("disabled", false)
5001
+ val buttonColor = craftNativeOptionalString(props.opt("color"))
5002
+ ?: craftNativeOptionalString(style.opt("color"))
5003
+ setTextColor(buttonColor?.let { color(it, defaultTextColor) } ?: defaultTextColor)
5004
+ isEnabled = !craftNativeBoolean(props.opt("disabled"))
4966
5005
  val handler = craftNativeEventName(events, "onPress", "onClick")
4967
5006
  if (handler.isNotBlank()) screen.handlers[this] = handler else screen.handlers.remove(this)
4968
5007
  setOnClickListener {
@@ -4971,13 +5010,13 @@ class MainActivity : AppCompatActivity() {
4971
5010
  }
4972
5011
  }
4973
5012
  "Checkbox" -> (previous as? CheckBox ?: CheckBox(this)).apply {
4974
- text = craftNativeOptionalString(props.opt("label")) ?: childText(children)
5013
+ text = (props.opt("label") as? String) ?: childText(children)
4975
5014
  configureText(this, text.toString(), style)
4976
- val requested = props.opt("checked") ?: props.opt("value")
4977
- val checked = when (requested) {
4978
- is Boolean -> requested
4979
- is Number -> requested.toDouble() != 0.0
4980
- is String -> requested.equals("true", ignoreCase = true) || requested == "1"
5015
+ val checkboxChecked = props.opt("checked")
5016
+ val checkboxValue = props.opt("value")
5017
+ val checked = when {
5018
+ checkboxChecked is Boolean -> checkboxChecked
5019
+ checkboxValue is Boolean -> checkboxValue
4981
5020
  else -> false
4982
5021
  }
4983
5022
  setOnCheckedChangeListener(null)
@@ -4985,11 +5024,14 @@ class MainActivity : AppCompatActivity() {
4985
5024
  // an indeterminate control must announce "mixed" and its next
4986
5025
  // user toggle should transition to checked, not unchecked.
4987
5026
  isChecked = checked
4988
- isActivated = props.optBoolean("indeterminate", false)
4989
- isEnabled = !props.optBoolean("disabled", false)
5027
+ val indeterminate = (props.opt("indeterminate") as? Boolean) == true
5028
+ isActivated = indeterminate
5029
+ isEnabled = !craftNativeBoolean(props.opt("disabled"))
4990
5030
  val checkboxColor = craftNativeOptionalString(props.opt("color"))
4991
5031
  ?: craftNativeOptionalString(style.opt("color"))
4992
- buttonTintList = checkboxColor?.let { ColorStateList.valueOf(color(it, defaultTextColor)) }
5032
+ buttonTintList = checkboxColor
5033
+ ?.let { runCatching { craftNativeColor(it) }.getOrNull() }
5034
+ ?.let { ColorStateList.valueOf(it) }
4993
5035
  val handler = craftNativeEventName(events, "onValueChange", "onChange")
4994
5036
  if (handler.isNotBlank()) screen.handlers[this] = handler else screen.handlers.remove(this)
4995
5037
  setOnCheckedChangeListener { _, value ->
@@ -5013,16 +5055,17 @@ class MainActivity : AppCompatActivity() {
5013
5055
  val defaultPadding = inputPaddingDefaults.getOrPut(this) {
5014
5056
  intArrayOf(paddingLeft, paddingTop, paddingRight, paddingBottom)
5015
5057
  }
5016
- hint = craftNativeOptionalString(props.opt("placeholder")).orEmpty()
5058
+ hint = (props.opt("placeholder") as? String).orEmpty()
5017
5059
  setHintTextColor(
5018
- craftNativeOptionalString(props.opt("placeholderTextColor"))
5019
- ?.let { ColorStateList.valueOf(color(it, Color.GRAY)) }
5060
+ (props.opt("placeholderTextColor") as? String)
5061
+ ?.let { runCatching { craftNativeColor(it) }.getOrNull() }
5062
+ ?.let { ColorStateList.valueOf(it) }
5020
5063
  ?: defaultHintColors,
5021
5064
  )
5022
- highlightColor = craftNativeOptionalString(props.opt("selectionColor"))
5023
- ?.let { color(it, defaultHighlightColor) }
5065
+ highlightColor = (props.opt("selectionColor") as? String)
5066
+ ?.let { runCatching { craftNativeColor(it) }.getOrNull() }
5024
5067
  ?: defaultHighlightColor
5025
- val maxLength = props.optInt("maxLength", 0)
5068
+ val maxLength = craftNativeInt(props.opt("maxLength"), 0)
5026
5069
  val currentLength = (filters.firstOrNull { it is InputFilter.LengthFilter } as? InputFilter.LengthFilter)?.max ?: 0
5027
5070
  if (currentLength != maxLength || (maxLength == 0 && filters.isNotEmpty())) {
5028
5071
  filters = if (maxLength > 0) arrayOf(InputFilter.LengthFilter(maxLength)) else emptyArray()
@@ -5031,11 +5074,11 @@ class MainActivity : AppCompatActivity() {
5031
5074
  if (inputType != wantedType) inputType = wantedType
5032
5075
  val wantedIme = imeOptionsFor(props)
5033
5076
  if (imeOptions != wantedIme) imeOptions = wantedIme
5034
- val multiline = props.optBoolean("multiline", false)
5077
+ val multiline = craftNativeBoolean(props.opt("multiline"))
5035
5078
  if (isSingleLine == multiline) isSingleLine = !multiline
5036
- val wantedLines = if (multiline) props.optInt("numberOfLines", Int.MAX_VALUE).coerceAtLeast(1) else 1
5079
+ val wantedLines = if (multiline) craftNativeInt(props.opt("numberOfLines"), Int.MAX_VALUE).coerceAtLeast(1) else 1
5037
5080
  if (maxLines != wantedLines) maxLines = wantedLines
5038
- isEnabled = props.optBoolean("editable", true)
5081
+ isEnabled = craftNativeBoolean(props.opt("editable"), true)
5039
5082
  configureTextStyle(this, style)
5040
5083
  // The style's padding is where the text starts, as on iOS and
5041
5084
  // the web; without one the field keeps its own.
@@ -5063,14 +5106,16 @@ class MainActivity : AppCompatActivity() {
5063
5106
  // them after keyed reconciliation.
5064
5107
  val autofillHint = autofillHintFor(props)
5065
5108
  if (autofillHint == null) setAutofillHints(*emptyArray()) else setAutofillHints(autofillHint)
5066
- val controlledValue = props.opt("value")?.takeUnless { it == JSONObject.NULL }?.toString()
5109
+ val controlledValue = props.opt("value") as? String
5067
5110
  val echoes = screen.inputEchoes.getOrPut(this) { CraftNativeInputEcho() }
5068
5111
  if (echoes.accept(controlledValue, text.toString()) && controlledValue != null) {
5069
5112
  screen.suppressedTextChanges.add(this)
5070
5113
  setTextPreservingSelection(this, controlledValue)
5071
5114
  }
5072
5115
  else if (this !== previous && screen.drafts.containsKey(identity)) setText(screen.drafts[identity])
5073
- else if (this !== previous && props.has("defaultValue") && !props.isNull("defaultValue")) setText(props.optString("defaultValue"))
5116
+ else if (this !== previous && props.has("defaultValue") && !props.isNull("defaultValue")) {
5117
+ (props.opt("defaultValue") as? String)?.let(::setText)
5118
+ }
5074
5119
  applyTextDecoration(this, style)
5075
5120
  screen.inputIdentities[this] = identity
5076
5121
  val handler = craftNativeEventName(events, "onChange", "onChangeText")
@@ -5105,7 +5150,7 @@ class MainActivity : AppCompatActivity() {
5105
5150
  send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject().put("text", text.toString())))
5106
5151
  true
5107
5152
  }
5108
- if (props.optBoolean("autoFocus", false) && !isFocused) post {
5153
+ if (craftNativeBoolean(props.opt("autoFocus")) && !isFocused) post {
5109
5154
  if (!isFocused) {
5110
5155
  requestFocus()
5111
5156
  (getSystemService(INPUT_METHOD_SERVICE) as? android.view.inputmethod.InputMethodManager)
@@ -5135,7 +5180,7 @@ class MainActivity : AppCompatActivity() {
5135
5180
  val itemOptions = props.optJSONArray("items")?.let { items ->
5136
5181
  (0 until items.length()).mapNotNull { index ->
5137
5182
  val item = items.optJSONObject(index) ?: return@mapNotNull null
5138
- val label = craftNativeOptionalString(item.opt("label")) ?: return@mapNotNull null
5183
+ val label = (item.opt("label") as? String)?.takeIf { it.isNotEmpty() } ?: return@mapNotNull null
5139
5184
  craftNativePickerValue(item.opt("value"))?.let { it to label } ?: (label to label)
5140
5185
  }
5141
5186
  }.orEmpty()
@@ -5143,7 +5188,7 @@ class MainActivity : AppCompatActivity() {
5143
5188
  val option = children.optJSONObject(index) ?: return@mapNotNull null
5144
5189
  if (option.optString("type") != "Text") return@mapNotNull null
5145
5190
  val label = (option.optJSONArray("children") ?: JSONArray()).let { text ->
5146
- (0 until text.length()).joinToString("") { text.optString(it) }
5191
+ (0 until text.length()).mapNotNull { text.opt(it) as? String }.joinToString("")
5147
5192
  }
5148
5193
  if (label.isBlank()) return@mapNotNull null
5149
5194
  val optionProps = option.optJSONObject("props")
@@ -5162,7 +5207,7 @@ class MainActivity : AppCompatActivity() {
5162
5207
  val requested = craftNativePickerValue(props.opt("selectedValue"))
5163
5208
  ?: craftNativePickerValue(props.opt("value")).orEmpty()
5164
5209
  val selected = options.indexOfFirst { it.first == requested }.takeIf { it >= 0 } ?: 0
5165
- isEnabled = !props.optBoolean("disabled", false)
5210
+ isEnabled = !craftNativeBoolean(props.opt("disabled"))
5166
5211
  // Mounting a controlled picker must not look like a user
5167
5212
  // change. Android may dispatch the initial adapter selection
5168
5213
  // during the first layout pass; iOS only reports a choice
@@ -5192,7 +5237,7 @@ class MainActivity : AppCompatActivity() {
5192
5237
  }
5193
5238
  }
5194
5239
  "Modal" -> (previous as? CraftNativeModalView ?: CraftNativeModalView(this)).apply {
5195
- transparent = props.optBoolean("transparent", false)
5240
+ transparent = craftNativeBoolean(props.opt("transparent"))
5196
5241
  content.configure(style, resources.displayMetrics.density)
5197
5242
  reconcileChildren(screen, content, children, identity, next, style, previousControls)
5198
5243
  // Dynamic :visible bindings arrive as a display style mutation;
@@ -5201,7 +5246,7 @@ class MainActivity : AppCompatActivity() {
5201
5246
  // An explicit visible prop owns modal visibility. The
5202
5247
  // binding runtime may leave a stale display:none style
5203
5248
  // from the previous hidden state when it patches props.
5204
- props.optBoolean("visible", false)
5249
+ craftNativeBoolean(props.opt("visible"))
5205
5250
  } else {
5206
5251
  style.has("display") && style.optString("display") != "none"
5207
5252
  }
@@ -5237,7 +5282,7 @@ class MainActivity : AppCompatActivity() {
5237
5282
  }
5238
5283
  "TouchableOpacity", "TouchableHighlight", "Pressable" -> (previous as? CraftNativeFlexLayout ?: CraftNativeFlexLayout(this)).apply {
5239
5284
  configureContainer(this, style)
5240
- val disabled = props.optBoolean("disabled", false)
5285
+ val disabled = craftNativeBoolean(props.opt("disabled"))
5241
5286
  screen.pressActiveOpacities[this] = craftNativeFiniteFloat(props, "activeOpacity", 0.5f)
5242
5287
  screen.pressBaseOpacities[this] = craftNativeFiniteFloat(style, "opacity", 1f)
5243
5288
  if (type == "TouchableHighlight" && !disabled) {
@@ -5254,24 +5299,29 @@ class MainActivity : AppCompatActivity() {
5254
5299
  val requestedValue = props.opt("value")
5255
5300
  val requestedChecked = props.opt("checked")
5256
5301
  isChecked = when {
5257
- requestedValue != null && requestedValue != JSONObject.NULL -> props.optBoolean("value")
5258
- requestedChecked == null || requestedChecked == JSONObject.NULL -> false
5302
+ requestedValue != null && requestedValue != JSONObject.NULL -> requestedValue as? Boolean ?: false
5259
5303
  requestedChecked is Boolean -> requestedChecked
5260
- requestedChecked is Number -> requestedChecked.toDouble() != 0.0
5261
- requestedChecked is String -> requestedChecked.isBlank() || requestedChecked.equals("true", ignoreCase = true)
5262
- else -> props.optBoolean("checked", false)
5304
+ else -> false
5263
5305
  }
5264
- isEnabled = !props.optBoolean("disabled", false)
5306
+ isEnabled = !craftNativeBoolean(props.opt("disabled"))
5265
5307
  props.optJSONObject("trackColor")?.let { track ->
5266
- val falseColor = color(track.optString("false"), Color.GRAY)
5267
- val trueColor = color(track.optString("true"), falseColor)
5268
- trackTintList = ColorStateList(
5269
- arrayOf(intArrayOf(android.R.attr.state_checked), intArrayOf()),
5270
- intArrayOf(trueColor, falseColor),
5271
- )
5308
+ val falseRaw = craftNativeOptionalString(track.opt("false"))
5309
+ val trueRaw = craftNativeOptionalString(track.opt("true"))
5310
+ val falseColor = falseRaw?.let { runCatching { craftNativeColor(it) }.getOrNull() }
5311
+ val trueColor = trueRaw?.let { runCatching { craftNativeColor(it) }.getOrNull() }
5312
+ trackTintList = if ((falseRaw != null && falseColor == null) || (trueRaw != null && trueColor == null)) {
5313
+ null
5314
+ } else {
5315
+ val resolvedFalse = falseColor ?: Color.GRAY
5316
+ ColorStateList(
5317
+ arrayOf(intArrayOf(android.R.attr.state_checked), intArrayOf()),
5318
+ intArrayOf(trueColor ?: resolvedFalse, resolvedFalse),
5319
+ )
5320
+ }
5272
5321
  } ?: run { trackTintList = null }
5273
5322
  craftNativeOptionalString(props.opt("thumbColor"))
5274
- ?.let { thumbTintList = ColorStateList.valueOf(color(it, Color.WHITE)) }
5323
+ ?.let { runCatching { craftNativeColor(it) }.getOrNull() }
5324
+ ?.let { thumbTintList = ColorStateList.valueOf(it) }
5275
5325
  ?: run { thumbTintList = null }
5276
5326
  val handler = craftNativeEventName(events, "onValueChange", "onChange")
5277
5327
  if (handler.isNotBlank()) screen.handlers[this] = handler else screen.handlers.remove(this)
@@ -5281,11 +5331,11 @@ class MainActivity : AppCompatActivity() {
5281
5331
  }
5282
5332
  }
5283
5333
  "Slider" -> (previous as? SeekBar ?: SeekBar(this)).apply {
5284
- val minimum = craftNativeFiniteDouble(props, "minimumValue", 0.0)
5285
- val maximumCandidate = craftNativeFiniteDouble(props, "maximumValue", 1.0)
5334
+ val minimum = craftNativeFiniteNumber(props, "minimumValue", 0.0)
5335
+ val maximumCandidate = craftNativeFiniteNumber(props, "maximumValue", 1.0)
5286
5336
  val maximum = if (maximumCandidate > minimum) maximumCandidate else minimum + Double.MIN_VALUE
5287
5337
  setOnSeekBarChangeListener(null)
5288
- val step = craftNativeFiniteDouble(props, "step", 0.0).takeIf { it > 0 }
5338
+ val step = craftNativeFiniteNumber(props, "step", 0.0).takeIf { it > 0 }
5289
5339
  // A stepped slider needs one native position per authored
5290
5340
  // step; a fixed 1,000-position SeekBar silently made large
5291
5341
  // ranges unable to represent most valid values. Continuous
@@ -5299,7 +5349,7 @@ class MainActivity : AppCompatActivity() {
5299
5349
  .toInt()
5300
5350
  } ?: 1000
5301
5351
  max = progressMax
5302
- val requested = craftNativeFiniteDouble(props, "value", minimum)
5352
+ val requested = craftNativeFiniteNumber(props, "value", minimum)
5303
5353
  val value = (if (step != null) minimum + ((requested - minimum) / step).roundToInt() * step else requested)
5304
5354
  .coerceIn(minimum, maximum)
5305
5355
  progress = if (step != null) {
@@ -5308,16 +5358,16 @@ class MainActivity : AppCompatActivity() {
5308
5358
  (((value - minimum) / (maximum - minimum)) * max).roundToInt().coerceIn(0, max)
5309
5359
  }
5310
5360
  if (step != null) screen.sliderSteps[this] = step else screen.sliderSteps.remove(this)
5311
- craftNativeOptionalString(props.opt("minimumTrackTintColor"))
5312
- ?.let { ColorStateList.valueOf(color(it, Color.WHITE)) }
5313
- ?: run { progressTintList = null }
5314
- craftNativeOptionalString(props.opt("maximumTrackTintColor"))
5315
- ?.let { ColorStateList.valueOf(color(it, Color.GRAY)) }
5316
- ?: run { progressBackgroundTintList = null }
5317
- craftNativeOptionalString(props.opt("thumbTintColor"))
5318
- ?.let { ColorStateList.valueOf(color(it, Color.WHITE)) }
5319
- ?: run { thumbTintList = null }
5320
- isEnabled = !props.optBoolean("disabled", false)
5361
+ progressTintList = craftNativeOptionalString(props.opt("minimumTrackTintColor"))
5362
+ ?.let { runCatching { craftNativeColor(it) }.getOrNull() }
5363
+ ?.let { ColorStateList.valueOf(it) }
5364
+ progressBackgroundTintList = craftNativeOptionalString(props.opt("maximumTrackTintColor"))
5365
+ ?.let { runCatching { craftNativeColor(it) }.getOrNull() }
5366
+ ?.let { ColorStateList.valueOf(it) }
5367
+ thumbTintList = craftNativeOptionalString(props.opt("thumbTintColor"))
5368
+ ?.let { runCatching { craftNativeColor(it) }.getOrNull() }
5369
+ ?.let { ColorStateList.valueOf(it) }
5370
+ isEnabled = !craftNativeBoolean(props.opt("disabled"))
5321
5371
  val handler = craftNativeEventName(events, "onValueChange", "onChange")
5322
5372
  if (handler.isNotBlank()) screen.handlers[this] = handler else screen.handlers.remove(this)
5323
5373
  val complete = craftNativeEventName(events, "onSlidingComplete")
@@ -5351,12 +5401,12 @@ class MainActivity : AppCompatActivity() {
5351
5401
  else -> 1f
5352
5402
  }
5353
5403
  indicatorScale = scale
5354
- val animating = props.optBoolean("animating", true)
5404
+ val animating = craftNativeBoolean(props.opt("animating"), true)
5355
5405
  isIndeterminate = animating
5356
- visibility = if (!animating && props.optBoolean("hidesWhenStopped", true)) View.GONE else View.VISIBLE
5357
- craftNativeOptionalString(props.opt("color"))
5358
- ?.let { ColorStateList.valueOf(color(it, Color.WHITE)) }
5359
- ?: run { indeterminateTintList = null }
5406
+ visibility = if (!animating && craftNativeBoolean(props.opt("hidesWhenStopped"), true)) View.GONE else View.VISIBLE
5407
+ indeterminateTintList = craftNativeOptionalString(props.opt("color"))
5408
+ ?.let { runCatching { craftNativeColor(it) }.getOrNull() }
5409
+ ?.let { ColorStateList.valueOf(it) }
5360
5410
  }
5361
5411
  "Icon" -> (previous as? CraftNativeIconView ?: CraftNativeIconView(this)).apply {
5362
5412
  configureIcon(this, props, style)
@@ -5386,9 +5436,9 @@ class MainActivity : AppCompatActivity() {
5386
5436
  "center" -> ImageView.ScaleType.CENTER
5387
5437
  else -> ImageView.ScaleType.CENTER_INSIDE
5388
5438
  }
5389
- craftNativeOptionalString(style.opt("tintColor"))
5390
- ?.let { image.setColorFilter(color(it, Color.TRANSPARENT)) }
5391
- ?: image.clearColorFilter()
5439
+ val tint = craftNativeOptionalString(style.opt("tintColor"))
5440
+ ?.let { runCatching { craftNativeColor(it) }.getOrNull() }
5441
+ if (tint == null) image.clearColorFilter() else image.setColorFilter(tint)
5392
5442
  val onLoadStart = craftNativeEventName(events, "onLoadStart")
5393
5443
  if (onLoadStart.isNotBlank()) screen.imageLoadStartHandlers[image] = onLoadStart else screen.imageLoadStartHandlers.remove(image)
5394
5444
  val onLoad = craftNativeEventName(events, "onLoad")
@@ -5409,13 +5459,13 @@ class MainActivity : AppCompatActivity() {
5409
5459
  onPictureInPictureChanged = { updatePictureInPicture() }
5410
5460
  configure(
5411
5461
  source = props.opt("source") ?: props.opt("src"),
5412
- autoplay = props.optBoolean("autoplay", props.optBoolean("autoPlay", false)),
5413
- paused = props.optBoolean("paused", false),
5414
- loop = props.optBoolean("loop", false),
5415
- muted = props.optBoolean("muted", false),
5416
- controls = props.optBoolean("controls", false),
5462
+ autoplay = craftNativeBoolean(props.opt("autoplay"), craftNativeBoolean(props.opt("autoPlay"))),
5463
+ paused = craftNativeBoolean(props.opt("paused")),
5464
+ loop = craftNativeBoolean(props.opt("loop")),
5465
+ muted = craftNativeBoolean(props.opt("muted")),
5466
+ controls = craftNativeBoolean(props.opt("controls")),
5417
5467
  resizeMode = craftNativeResizeMode(style, props),
5418
- pictureInPicture = props.optBoolean("pictureInPicture", false),
5468
+ pictureInPicture = craftNativeBoolean(props.opt("pictureInPicture")),
5419
5469
  )
5420
5470
  }
5421
5471
  "Map" -> (previous as? CraftNativeMapView ?: CraftNativeMapView(this)).apply {
@@ -5430,21 +5480,21 @@ class MainActivity : AppCompatActivity() {
5430
5480
  drawing = node
5431
5481
  }
5432
5482
  "ScrollView", "SectionList" -> (previous as? CraftNativeScrollView ?: CraftNativeScrollView(this)).apply {
5433
- val horizontal = props.optBoolean("horizontal", false) || style.optString("flexDirection") in setOf("row", "row-reverse")
5483
+ val horizontal = craftNativeBoolean(props.opt("horizontal")) || style.optString("flexDirection") in setOf("row", "row-reverse")
5434
5484
  setHorizontal(horizontal)
5435
5485
  setNativeNestedScrollingEnabled(true)
5436
5486
  setScrollIndicators(
5437
- props.optBoolean("showsVerticalScrollIndicator", true),
5438
- props.optBoolean("showsHorizontalScrollIndicator", true),
5487
+ craftNativeBoolean(props.opt("showsVerticalScrollIndicator"), true),
5488
+ craftNativeBoolean(props.opt("showsHorizontalScrollIndicator"), true),
5439
5489
  )
5440
- setScrollEnabled(props.optBoolean("scrollEnabled", true))
5490
+ setScrollEnabled(craftNativeBoolean(props.opt("scrollEnabled"), true))
5441
5491
  setKeyboardShouldPersistTaps(props.optString("keyboardShouldPersistTaps", "never"))
5442
- setBounces(props.optBoolean("bounces", true))
5492
+ setBounces(craftNativeBoolean(props.opt("bounces"), true))
5443
5493
  setAlwaysBounce(
5444
- props.optBoolean("alwaysBounceVertical", !horizontal),
5445
- props.optBoolean("alwaysBounceHorizontal", horizontal),
5494
+ craftNativeBoolean(props.opt("alwaysBounceVertical"), !horizontal),
5495
+ craftNativeBoolean(props.opt("alwaysBounceHorizontal"), horizontal),
5446
5496
  )
5447
- setPagingEnabled(props.optBoolean("pagingEnabled", false))
5497
+ setPagingEnabled(craftNativeBoolean(props.opt("pagingEnabled")))
5448
5498
  val refreshNode = (0 until children.length()).asSequence()
5449
5499
  .mapNotNull { children.optJSONObject(it) }
5450
5500
  .firstOrNull { canonicalType(it.optString("type")) == "RefreshControl" }
@@ -5457,8 +5507,8 @@ class MainActivity : AppCompatActivity() {
5457
5507
  val refreshHandler = refreshHandlerName?.let { handler ->
5458
5508
  { send(screen, "EVENT", JSONObject().put("handlerName", handler).put("nativeEvent", JSONObject())) }
5459
5509
  }
5460
- setRefreshHandler(refreshHandler, refreshProps.optBoolean("refreshing", props.optBoolean("refreshing", false)))
5461
- setRefreshAppearance(refreshProps, resources.displayMetrics.density)
5510
+ setRefreshHandler(refreshHandler, craftNativeBoolean(refreshProps.opt("refreshing"), craftNativeBoolean(props.opt("refreshing"))))
5511
+ setRefreshAppearance(refreshProps, resources.displayMetrics.density, refreshTintFallback(screen))
5462
5512
  val onScroll = craftNativeEventName(events, "onScroll")
5463
5513
  if (onScroll.isNotBlank()) screen.scrollHandlers[this] = onScroll else screen.scrollHandlers.remove(this)
5464
5514
  val onBegin = craftNativeEventName(events, "onScrollBeginDrag")
@@ -5528,9 +5578,15 @@ class MainActivity : AppCompatActivity() {
5528
5578
  }
5529
5579
  "FlatList" -> (previous as? CraftNativeRefreshableFlatList ?: CraftNativeRefreshableFlatList(this)).apply {
5530
5580
  val nativeList = list
5531
- configureRefresh(craftNativeEventName(events, "onRefresh").takeIf { it.isNotBlank() }?.let { handler ->
5581
+ val propRefresh = props.optJSONObject("refreshControl") ?: JSONObject()
5582
+ val propRefreshEvents = propRefresh.optJSONObject("events") ?: JSONObject()
5583
+ val refreshHandlerName = craftNativeEventName(propRefreshEvents, "onRefresh")
5584
+ .takeIf { it.isNotBlank() }
5585
+ ?: craftNativeEventName(events, "onRefresh").takeIf { it.isNotBlank() }
5586
+ configureRefresh(refreshHandlerName?.let { handler ->
5532
5587
  { send(screen, "EVENT", JSONObject().put("handlerName", handler).put("nativeEvent", JSONObject())) }
5533
- }, props.optBoolean("refreshing", false))
5588
+ }, craftNativeBoolean(propRefresh.opt("refreshing"), craftNativeBoolean(props.opt("refreshing"))))
5589
+ setRefreshAppearance(propRefresh, refreshTintFallback(screen))
5534
5590
  nativeList.setKeyboardShouldPersistTaps(props.optString("keyboardShouldPersistTaps", "never"))
5535
5591
  nativeList.setContentContainerStyle(props.optJSONObject("contentContainerStyle"), resources.displayMetrics.density)
5536
5592
  nativeList.setKeyboardDismissMode(props.optString("keyboardDismissMode")) {
@@ -5569,7 +5625,7 @@ class MainActivity : AppCompatActivity() {
5569
5625
  if (underlayHideHandler != null) screen.pressUnderlayHideHandlers[control] = underlayHideHandler else screen.pressUnderlayHideHandlers.remove(control)
5570
5626
  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") {
5571
5627
  val handler = craftNativeEventName(events, "onPress", "onClick")
5572
- val disabled = props.optBoolean("disabled", false)
5628
+ val disabled = craftNativeBoolean(props.opt("disabled"))
5573
5629
  val target = listOf(props.opt("href"), props.opt("to"))
5574
5630
  .firstNotNullOfOrNull(::craftNativeOptionalString)
5575
5631
  ?.trim()
@@ -5640,8 +5696,8 @@ class MainActivity : AppCompatActivity() {
5640
5696
  }
5641
5697
  control.isEnabled = !disabled
5642
5698
  }
5643
- val longPressDisabled = props.optBoolean("disabled", false) ||
5644
- props.optJSONObject("accessibilityState")?.optBoolean("disabled", false) == true
5699
+ val longPressDisabled = craftNativeBoolean(props.opt("disabled")) ||
5700
+ craftNativeBoolean(props.optJSONObject("accessibilityState")?.opt("disabled"))
5645
5701
  val longPressHandler = craftNativeEventName(events, "onLongPress").takeUnless { longPressDisabled } ?: ""
5646
5702
  if (longPressHandler.isNotBlank()) {
5647
5703
  screen.longPressHandlers[control] = longPressHandler
@@ -5686,8 +5742,8 @@ class MainActivity : AppCompatActivity() {
5686
5742
  next[identity] = control
5687
5743
  applyViewStyle(control, style)
5688
5744
  if (type == "ActivityIndicator") {
5689
- val animating = props.optBoolean("animating", true)
5690
- val hidesWhenStopped = props.optBoolean("hidesWhenStopped", true)
5745
+ val animating = craftNativeBoolean(props.opt("animating"), true)
5746
+ val hidesWhenStopped = craftNativeBoolean(props.opt("hidesWhenStopped"), true)
5691
5747
  control.visibility = if (style.optString("display") == "none" || (!animating && hidesWhenStopped)) {
5692
5748
  View.GONE
5693
5749
  } else {
@@ -5800,8 +5856,7 @@ class MainActivity : AppCompatActivity() {
5800
5856
  is JSONObject -> makeView(screen, child, "$path.$index", next, previousControls)
5801
5857
  is String -> TextView(this).apply {
5802
5858
  includeFontPadding = false
5803
- text = child
5804
- setTextColor(defaultTextColor)
5859
+ configureText(this, child, craftNativeTypography(style))
5805
5860
  }
5806
5861
  else -> null
5807
5862
  }
@@ -5859,12 +5914,12 @@ class MainActivity : AppCompatActivity() {
5859
5914
  }
5860
5915
  val threshold = craftNativeNonNegativeDouble(props, "threshold", 0.1)
5861
5916
  val authoredThreshold = craftNativeNonNegativeDouble(props, "onEndReachedThreshold", threshold)
5862
- val horizontal = props.optBoolean("horizontal", false)
5917
+ val horizontal = craftNativeBoolean(props.opt("horizontal"))
5863
5918
  list.apply(
5864
5919
  nodes = nodes,
5865
5920
  horizontal = horizontal,
5866
- columns = props.optInt("numColumns", 1),
5867
- inverted = props.optBoolean("inverted", false),
5921
+ columns = craftNativeInt(props.opt("numColumns"), 1),
5922
+ inverted = craftNativeBoolean(props.opt("inverted")),
5868
5923
  endReachedThreshold = authoredThreshold,
5869
5924
  renderItem = { node, itemIdentity, _ ->
5870
5925
  val rows = screen.flatListRows.getOrPut(list) { mutableMapOf() }
@@ -5884,16 +5939,16 @@ class MainActivity : AppCompatActivity() {
5884
5939
  },
5885
5940
  )
5886
5941
  list.setScrollIndicators(
5887
- props.optBoolean("showsVerticalScrollIndicator", true),
5888
- props.optBoolean("showsHorizontalScrollIndicator", true),
5942
+ craftNativeBoolean(props.opt("showsVerticalScrollIndicator"), true),
5943
+ craftNativeBoolean(props.opt("showsHorizontalScrollIndicator"), true),
5889
5944
  )
5890
- list.setScrollEnabled(props.optBoolean("scrollEnabled", true))
5891
- list.setPagingEnabled(props.optBoolean("pagingEnabled", false))
5945
+ list.setScrollEnabled(craftNativeBoolean(props.opt("scrollEnabled"), true))
5946
+ list.setPagingEnabled(craftNativeBoolean(props.opt("pagingEnabled")))
5892
5947
  list.setScrollTarget(props.opt("scrollTarget"))
5893
5948
  list.setBounceOptions(
5894
- props.optBoolean("bounces", true),
5895
- props.optBoolean("alwaysBounceVertical", !horizontal),
5896
- props.optBoolean("alwaysBounceHorizontal", horizontal),
5949
+ craftNativeBoolean(props.opt("bounces"), true),
5950
+ craftNativeBoolean(props.opt("alwaysBounceVertical"), !horizontal),
5951
+ craftNativeBoolean(props.opt("alwaysBounceHorizontal"), horizontal),
5897
5952
  )
5898
5953
  }
5899
5954
 
@@ -6046,10 +6101,13 @@ class MainActivity : AppCompatActivity() {
6046
6101
  }
6047
6102
  if (view is ImageView) {
6048
6103
  val tint = style.opt("tintColor") as? String
6049
- if (tint.isNullOrBlank() || tint == "null" || tint == "undefined") {
6104
+ val resolvedTint = tint
6105
+ ?.takeUnless { it.isBlank() || it == "null" || it == "undefined" }
6106
+ ?.let { runCatching { craftNativeColor(it) }.getOrNull() }
6107
+ if (resolvedTint == null) {
6050
6108
  view.clearColorFilter()
6051
6109
  } else {
6052
- view.setColorFilter(color(tint, Color.TRANSPARENT))
6110
+ view.setColorFilter(resolvedTint)
6053
6111
  }
6054
6112
  }
6055
6113
  }
@@ -6120,6 +6178,22 @@ class MainActivity : AppCompatActivity() {
6120
6178
  configureTextStyle(view, style)
6121
6179
  }
6122
6180
 
6181
+ /**
6182
+ * Raw strings inside a native container are implicit Text nodes. Keep
6183
+ * their typography, but not the parent's padding or layout, just like
6184
+ * the iOS reconciler does for the same child shape.
6185
+ */
6186
+ private fun craftNativeTypography(style: JSONObject): JSONObject = JSONObject().also { typography ->
6187
+ listOf(
6188
+ "color", "letterSpacing", "lineHeight", "fontSize", "fontFamily",
6189
+ "fontWeight", "fontStyle", "textAlign", "textTransform",
6190
+ "textDecorationLine", "textDecorationStyle", "textDecorationColor",
6191
+ "textShadowColor", "textShadowRadius", "textShadowOffset",
6192
+ ).forEach { key ->
6193
+ if (style.has(key) && !style.isNull(key)) typography.put(key, style.opt(key))
6194
+ }
6195
+ }
6196
+
6123
6197
  private fun configureIcon(view: CraftNativeIconView, props: JSONObject, style: JSONObject) {
6124
6198
  val symbol = craftNativeOptionalString(props.opt("symbol"))
6125
6199
  ?: craftNativeOptionalString(props.opt("name")).orEmpty()
@@ -6139,11 +6213,22 @@ class MainActivity : AppCompatActivity() {
6139
6213
  )
6140
6214
  view.vector = vector
6141
6215
  view.text = if (vector != null) "" else androidIconGlyph(symbol)
6142
- val weight = when (style.optString("fontWeight")) {
6143
- "bold", "700", "800", "900" -> Typeface.BOLD
6144
- else -> Typeface.NORMAL
6216
+ val weight = when (craftNativeFontWeight(style.opt("fontWeight"))) {
6217
+ "100" -> 100
6218
+ "200" -> 200
6219
+ "300" -> 300
6220
+ "500" -> 500
6221
+ "600" -> 600
6222
+ "bold", "700" -> 700
6223
+ "800" -> 800
6224
+ "900" -> 900
6225
+ else -> 400
6226
+ }
6227
+ view.typeface = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
6228
+ Typeface.create("sans-serif", weight, false)
6229
+ } else {
6230
+ Typeface.create("sans-serif", if (weight >= 600) Typeface.BOLD else Typeface.NORMAL)
6145
6231
  }
6146
- view.typeface = Typeface.create("sans-serif", weight)
6147
6232
  val hasExplicitWidth = style.has("width") && !style.isNull("width")
6148
6233
  val hasExplicitHeight = style.has("height") && !style.isNull("height")
6149
6234
  if (!hasExplicitWidth && !hasExplicitHeight) {
@@ -6196,7 +6281,8 @@ class MainActivity : AppCompatActivity() {
6196
6281
  private fun configureTextStyle(view: TextView, style: JSONObject) {
6197
6282
  view.setTextColor(color(style.optString("color"), defaultTextColor))
6198
6283
  view.setTextSize(TypedValue.COMPLEX_UNIT_SP, craftNativeFiniteFloat(style, "fontSize", 16f))
6199
- val weight = when (style.optString("fontWeight")) {
6284
+ val fontWeight = style.opt("fontWeight") as? String
6285
+ val weight = when (fontWeight) {
6200
6286
  "bold" -> 700
6201
6287
  "100", "200", "300", "400", "500", "600", "700", "800", "900" -> style.optInt("fontWeight")
6202
6288
  else -> 400
@@ -6221,12 +6307,12 @@ class MainActivity : AppCompatActivity() {
6221
6307
  Layout.JUSTIFICATION_MODE_NONE
6222
6308
  }
6223
6309
  }
6224
- view.letterSpacing = style.optDouble("letterSpacing", Double.NaN)
6310
+ view.letterSpacing = craftNativeFiniteDouble(style, "letterSpacing", Double.NaN)
6225
6311
  .takeIf { it.isFinite() }
6226
6312
  ?.let { if (view.textSize > 0) dp(it).toFloat() / view.textSize else 0f }
6227
6313
  ?: 0f
6228
6314
  view.setLineSpacing(
6229
- style.optDouble("lineHeight", Double.NaN).takeIf { it.isFinite() }?.let { dp(it) - view.textSize } ?: 0f,
6315
+ craftNativeFiniteDouble(style, "lineHeight", Double.NaN).takeIf { it.isFinite() }?.let { dp(it) - view.textSize } ?: 0f,
6230
6316
  1f
6231
6317
  )
6232
6318
  val shadowColor = style.optString("textShadowColor").takeIf { it.isNotBlank() }
@@ -6458,7 +6544,7 @@ class MainActivity : AppCompatActivity() {
6458
6544
  if (type == "Icon") "image" else type.lowercase()
6459
6545
  }
6460
6546
  val state = props.optJSONObject("accessibilityState")
6461
- val disabled = props.optBoolean("disabled", false) || state?.optBoolean("disabled", false) == true
6547
+ val disabled = craftNativeBoolean(props.opt("disabled")) || craftNativeBoolean(state?.opt("disabled"))
6462
6548
  if (type !in setOf("Button", "Link", "Checkbox", "TextInput", "Picker", "DatePicker", "Modal", "Switch", "Slider", "ActivityIndicator")) {
6463
6549
  view.isEnabled = !disabled
6464
6550
  } else if (disabled) {
@@ -6466,7 +6552,7 @@ class MainActivity : AppCompatActivity() {
6466
6552
  }
6467
6553
  ViewCompat.setStateDescription(
6468
6554
  view,
6469
- if (type == "Checkbox" && props.optBoolean("indeterminate", false)) "mixed" else null,
6555
+ if (type == "Checkbox" && (props.opt("indeterminate") as? Boolean) == true) "mixed" else null,
6470
6556
  )
6471
6557
  ViewCompat.setAccessibilityDelegate(view, object : AccessibilityDelegateCompat() {
6472
6558
  override fun onInitializeAccessibilityNodeInfo(host: View, info: AccessibilityNodeInfoCompat) {
@@ -6482,8 +6568,8 @@ class MainActivity : AppCompatActivity() {
6482
6568
  info.roleDescription = role.takeIf { it !in setOf("view", "text", "textinput", "scrollview") }
6483
6569
  info.hintText = host.tooltipText
6484
6570
  info.isEnabled = host.isEnabled && !disabled
6485
- info.isSelected = state?.optBoolean("selected", false) == true
6486
- info.isChecked = state?.optBoolean("checked", false) == true
6571
+ info.isSelected = craftNativeBoolean(state?.opt("selected"))
6572
+ info.isChecked = craftNativeBoolean(state?.opt("checked"))
6487
6573
  }
6488
6574
  })
6489
6575
  ViewCompat.setImportantForAccessibility(
@@ -6499,11 +6585,11 @@ class MainActivity : AppCompatActivity() {
6499
6585
  private fun color(raw: String, fallback: Int) = runCatching { craftNativeColor(raw) }.getOrDefault(fallback)
6500
6586
 
6501
6587
  private fun inputTypeFor(props: JSONObject): Int {
6502
- val base = if (props.optBoolean("multiline", false)) InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_MULTI_LINE
6588
+ val base = if (craftNativeBoolean(props.opt("multiline"))) InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_MULTI_LINE
6503
6589
  else InputType.TYPE_CLASS_TEXT
6504
6590
  // Text variations do not carry their input class or multiline flag.
6505
6591
  // Preserve both when selecting an email, URL, or search keyboard.
6506
- val keyboard = when (props.optString("keyboardType")) {
6592
+ val keyboard = when (props.opt("keyboardType") as? String) {
6507
6593
  "email-address" -> base or InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS
6508
6594
  "url" -> base or InputType.TYPE_TEXT_VARIATION_URI
6509
6595
  "numeric", "number-pad" -> InputType.TYPE_CLASS_NUMBER
@@ -6513,21 +6599,22 @@ class MainActivity : AppCompatActivity() {
6513
6599
  "visible-password" -> base or InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD
6514
6600
  else -> base
6515
6601
  }
6516
- val capitalization = when (props.optString("autoCapitalize")) {
6602
+ val capitalization = when (props.opt("autoCapitalize") as? String) {
6517
6603
  "characters" -> InputType.TYPE_TEXT_FLAG_CAP_CHARACTERS
6518
6604
  "words" -> InputType.TYPE_TEXT_FLAG_CAP_WORDS
6519
6605
  "sentences" -> InputType.TYPE_TEXT_FLAG_CAP_SENTENCES
6520
6606
  else -> 0
6521
6607
  }
6522
- val correction = if (props.optBoolean("autoCorrect", true)) InputType.TYPE_TEXT_FLAG_AUTO_CORRECT else 0
6523
- val secure = if (props.optBoolean("secureTextEntry", false)) InputType.TYPE_TEXT_VARIATION_PASSWORD else 0
6608
+ val correction = if (craftNativeBoolean(props.opt("autoCorrect"), true)) InputType.TYPE_TEXT_FLAG_AUTO_CORRECT else 0
6609
+ val secure = if (craftNativeBoolean(props.opt("secureTextEntry"))) InputType.TYPE_TEXT_VARIATION_PASSWORD else 0
6524
6610
  return keyboard or capitalization or correction or secure
6525
6611
  }
6526
6612
 
6527
6613
  /// The autofill hint for `textContentType` (iOS's names) or `autoComplete`
6528
6614
  /// (HTML's), so a password manager fills the field.
6529
6615
  private fun autofillHintFor(props: JSONObject): String? {
6530
- val raw = props.optString("textContentType").ifBlank { props.optString("autoComplete") }
6616
+ val raw = (props.opt("textContentType") as? String)?.takeIf { it.isNotBlank() }
6617
+ ?: (props.opt("autoComplete") as? String).orEmpty()
6531
6618
  return when (raw.replace("-", "").replace("_", "").lowercase()) {
6532
6619
  "username" -> View.AUTOFILL_HINT_USERNAME
6533
6620
  "nickname" -> "personNickname"
@@ -6558,7 +6645,7 @@ class MainActivity : AppCompatActivity() {
6558
6645
  }
6559
6646
  }
6560
6647
 
6561
- private fun imeOptionsFor(props: JSONObject): Int = when (props.optString("returnKeyType")) {
6648
+ private fun imeOptionsFor(props: JSONObject): Int = when (props.opt("returnKeyType") as? String) {
6562
6649
  "done" -> EditorInfo.IME_ACTION_DONE
6563
6650
  "go" -> EditorInfo.IME_ACTION_GO
6564
6651
  "next" -> EditorInfo.IME_ACTION_NEXT