@craft-native/android 0.0.189 → 0.0.191

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.189",
3
+ "version": "0.0.191",
4
4
  "homepage": "https://github.com/craft-native/craft/tree/main/packages/android#readme",
5
5
  "repository": {
6
6
  "type": "git",
@@ -576,6 +576,9 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
576
576
  private val buttons = LinearLayout(context)
577
577
  private val tabBar = LinearLayout(context)
578
578
  private val tabViews = mutableListOf<Pair<TextView, TextView>>()
579
+ private val tabIconText = mutableMapOf<String, String>()
580
+ private val tabImageURLs = mutableMapOf<String, String>()
581
+ private val tabImages = mutableMapOf<String, android.graphics.drawable.Drawable>()
579
582
  private val density = resources.displayMetrics.density
580
583
  private var selectedTab = 0
581
584
  private var swipeTracking = false
@@ -703,6 +706,7 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
703
706
 
704
707
  /** Draws `options` (a screen's `setOptions`) for a screen `depth` deep in its stack. */
705
708
  fun apply(options: JSONObject, depth: Int, fallbackTitle: String?) {
709
+ options.optJSONObject("tabItem")?.let(::updateTab)
706
710
  val hidden = options.optBoolean("hidden", false)
707
711
  val large = options.optBoolean("largeTitle", false) && depth == 0
708
712
  val title = options.optString("title").takeIf { options.has("title") } ?: fallbackTitle.orEmpty()
@@ -728,6 +732,42 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
728
732
  refreshColors()
729
733
  }
730
734
 
735
+ private fun updateTab(item: JSONObject) {
736
+ val id = item.optString("id")
737
+ val index = tabs.indexOfFirst { it.id == id }
738
+ if (index < 0) return
739
+ val cell = tabBar.getChildAt(index)
740
+ if (item.has("accessibilityLabel")) cell.contentDescription = item.optString("accessibilityLabel").takeUnless { item.isNull("accessibilityLabel") || it.isBlank() } ?: tabs[index].title
741
+ if (item.has("iconText")) {
742
+ tabIconText.remove(id)
743
+ item.optString("iconText").takeUnless { item.isNull("iconText") || it.isBlank() }?.let { tabIconText[id] = it.take(8) }
744
+ }
745
+ if (item.has("image")) {
746
+ val address = item.optString("image").takeIf { !item.isNull("image") && it.startsWith("https://") }
747
+ if (address != tabImageURLs[id]) {
748
+ tabImages.remove(id)
749
+ tabImageURLs.remove(id)
750
+ if (address != null) {
751
+ tabImageURLs[id] = address
752
+ Thread {
753
+ val image = runCatching {
754
+ val connection = java.net.URL(address).openConnection().apply { connectTimeout = 5000; readTimeout = 5000 }
755
+ val bitmap = connection.getInputStream().use { android.graphics.BitmapFactory.decodeStream(it) } ?: return@runCatching null
756
+ androidx.core.graphics.drawable.RoundedBitmapDrawableFactory.create(resources, android.graphics.Bitmap.createScaledBitmap(bitmap, dp(27), dp(27), true)).apply { isCircular = true }
757
+ }.getOrNull()
758
+ post {
759
+ if (tabImageURLs[id] == address && image != null) {
760
+ tabImages[id] = image
761
+ refreshColors()
762
+ }
763
+ }
764
+ }.start()
765
+ }
766
+ }
767
+ }
768
+ refreshColors()
769
+ }
770
+
731
771
  /** Light or dark changed: the bars follow the screen's background. */
732
772
  fun refreshColors() {
733
773
  val background = ContextCompat.getColor(context, R.color.background)
@@ -749,11 +789,12 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
749
789
  )).apply { setLayerInset(1, 0, maxOf(1, (density / 2).toInt()), 0, 0) }
750
790
  tabViews.forEachIndexed { index, (icon, label) ->
751
791
  val color = if (index == selectedTab) accent else muted
752
- icon.setTextColor(color)
792
+ val number = tabIconText[tabs[index].id]
793
+ icon.setTextColor(if (number != null) Color.parseColor("#FF2D55") else color)
753
794
  label.setTextColor(color)
754
- val vector = CraftNativeIcons.drawable(context, null, tabs[index].symbol, color)
795
+ val vector = tabImages[tabs[index].id] ?: if (number == null) CraftNativeIcons.drawable(context, null, tabs[index].symbol, color) else null
755
796
  icon.foreground = vector
756
- if (vector != null) icon.text = ""
797
+ icon.text = if (vector != null) "" else number ?: CraftNativeSymbols.glyph(tabs[index].symbol)
757
798
  }
758
799
  for (index in 0 until buttons.childCount) (buttons.getChildAt(index) as? TextView)?.setTextColor(accent)
759
800
  }
@@ -2653,6 +2653,37 @@ private class CraftNativeDatePickerView(context: Context) : FrameLayout(context)
2653
2653
  }
2654
2654
  }
2655
2655
 
2656
+ /** A field's pending native edits and the props acknowledged by the screen. */
2657
+ internal class CraftNativeInputEcho {
2658
+ private val pending = ArrayDeque<String>()
2659
+ private var rendered: String? = null
2660
+ private var initialized = false
2661
+
2662
+ fun record(value: String) {
2663
+ pending.addLast(value)
2664
+ while (pending.size > 64) pending.removeFirst()
2665
+ }
2666
+
2667
+ fun accept(value: String?, nativeValue: String): Boolean {
2668
+ if (value == null) {
2669
+ initialized = false
2670
+ rendered = null
2671
+ pending.clear()
2672
+ return false
2673
+ }
2674
+ // A retained field can render the same prop more than once, after
2675
+ // its echo was consumed. That repeated prop must not overwrite a
2676
+ // keystroke that has not made the native/JS round trip yet.
2677
+ val changed = !initialized || rendered != value
2678
+ initialized = true
2679
+ rendered = value
2680
+ if (!changed) return false
2681
+ val index = pending.indexOf(value)
2682
+ if (index >= 0) repeat(index + 1) { pending.removeFirst() }
2683
+ return nativeValue != value && index < 0
2684
+ }
2685
+ }
2686
+
2656
2687
  /**
2657
2688
  * A native screen's `<Video>`: the platform's VideoView.
2658
2689
  *
@@ -3261,7 +3292,7 @@ class MainActivity : AppCompatActivity() {
3261
3292
  * the screen answer with a value the field has already moved past;
3262
3293
  * that echo is not written back over what was typed since.
3263
3294
  */
3264
- val inputEchoes = mutableMapOf<EditText, ArrayDeque<String>>()
3295
+ val inputEchoes = mutableMapOf<EditText, CraftNativeInputEcho>()
3265
3296
  val controls = mutableMapOf<String, View>()
3266
3297
  // Protocol ids identify mutation targets; authored keys identify the
3267
3298
  // retained view. Keep both mappings so a targeted update can recover
@@ -4601,10 +4632,8 @@ class MainActivity : AppCompatActivity() {
4601
4632
  val autofillHint = autofillHintFor(props)
4602
4633
  if (autofillHint == null) setAutofillHints(*emptyArray()) else setAutofillHints(autofillHint)
4603
4634
  val controlledValue = props.opt("value")?.takeUnless { it == JSONObject.NULL }?.toString()
4604
- val echoes = screen.inputEchoes[this]
4605
- val echoIndex = if (controlledValue == null || echoes == null) -1 else echoes.indexOf(controlledValue)
4606
- if (echoIndex >= 0) repeat(echoIndex + 1) { echoes!!.removeFirst() }
4607
- if (controlledValue != null && text.toString() != controlledValue && echoIndex < 0) {
4635
+ val echoes = screen.inputEchoes.getOrPut(this) { CraftNativeInputEcho() }
4636
+ if (echoes.accept(controlledValue, text.toString()) && controlledValue != null) {
4608
4637
  screen.suppressedTextChanges.add(this)
4609
4638
  setTextPreservingSelection(this, controlledValue)
4610
4639
  }
@@ -4662,9 +4691,7 @@ class MainActivity : AppCompatActivity() {
4662
4691
  (screen.inputIdentities[this@apply] ?: identity).let { screen.drafts[it] = updated }
4663
4692
  if (screen.suppressedTextChanges.remove(this@apply)) return
4664
4693
  val current = screen.handlers[this@apply] ?: return
4665
- val echoes = screen.inputEchoes.getOrPut(this@apply) { ArrayDeque() }
4666
- echoes.addLast(updated)
4667
- while (echoes.size > 64) echoes.removeFirst()
4694
+ screen.inputEchoes.getOrPut(this@apply) { CraftNativeInputEcho() }.record(updated)
4668
4695
  send(screen, "EVENT", JSONObject()
4669
4696
  .put("handlerName", current)
4670
4697
  .put("nativeEvent", JSONObject().put("text", updated)))
@@ -5430,6 +5457,11 @@ class MainActivity : AppCompatActivity() {
5430
5457
  view.elevation = dp(craftNativeNonNegativeDouble(style, "elevation", 0.0)).toFloat()
5431
5458
  view.z = craftNativeFiniteFloat(style, "zIndex", 0f)
5432
5459
  val background = style.has("backgroundColor") && !style.isNull("backgroundColor")
5460
+ // VideoView's Surface is behind this window. An opaque View
5461
+ // background paints over the decoded picture (only rounded corners
5462
+ // showed it). Keep the outline/borders, with a transparent fill.
5463
+ val backgroundColor = if (view is CraftNativeVideoView) Color.TRANSPARENT
5464
+ else color(style.optString("backgroundColor"), Color.TRANSPARENT)
5433
5465
  val border = craftNativeNonNegativeDouble(style, "borderWidth", 0.0)
5434
5466
  val radius = craftNativeNonNegativeDouble(style, "borderRadius", 0.0)
5435
5467
  val sideBorderKeys = listOf("borderTopWidth", "borderRightWidth", "borderBottomWidth", "borderLeftWidth")
@@ -5452,14 +5484,14 @@ class MainActivity : AppCompatActivity() {
5452
5484
  if (background || border > 0 || radius > 0 || hasSideBorders) {
5453
5485
  if (hasSideBorders) {
5454
5486
  view.background = CraftNativeSideBorderDrawable(
5455
- color(style.optString("backgroundColor"), Color.TRANSPARENT),
5487
+ backgroundColor,
5456
5488
  cornerRadii,
5457
5489
  sideWidths,
5458
5490
  sideColors,
5459
5491
  )
5460
5492
  } else {
5461
5493
  view.background = GradientDrawable().apply {
5462
- setColor(color(style.optString("backgroundColor"), Color.TRANSPARENT))
5494
+ setColor(backgroundColor)
5463
5495
  if (hasCornerRadii) setCornerRadii(cornerRadii) else cornerRadius = dp(radius).toFloat()
5464
5496
  if (border > 0) {
5465
5497
  val borderColor = color(style.optString("borderColor"), Color.TRANSPARENT)