@onekeyfe/react-native-native-list 6.7.0-alpha.397 → 6.7.0-alpha.399

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.
Files changed (40) hide show
  1. package/android/src/main/java/com/onekey/nativelist/NativeListActivityRenderer.kt +23 -0
  2. package/android/src/main/java/com/onekey/nativelist/NativeListIdentityRenderer.kt +43 -15
  3. package/android/src/main/java/com/onekey/nativelist/NativeListLeadingVisual.kt +88 -19
  4. package/android/src/main/java/com/onekey/nativelist/NativeListModels.kt +94 -1
  5. package/android/src/main/java/com/onekey/nativelist/NativeListPrimitives.kt +29 -0
  6. package/android/src/test/java/com/margelo/nitro/nativelist/NativeListActivityHostClipPolicyTest.kt +29 -0
  7. package/android/src/test/java/com/margelo/nitro/nativelist/NativeListIdentityTrailingAxisTest.kt +36 -0
  8. package/android/src/test/java/com/margelo/nitro/nativelist/NativeListLeadingSlotFrameTest.kt +68 -0
  9. package/docs/ROW_TEMPLATES.md +4 -0
  10. package/docs/SPEC.md +180 -37
  11. package/ios/NativeListDesignAssets.swift +6 -0
  12. package/ios/NativeListIdentityRenderer.swift +76 -4
  13. package/ios/NativeListLeadingVisual.swift +70 -8
  14. package/ios/RNCNativeListView.swift +6 -1
  15. package/ios/Resources/NativeListIcons.xcassets/onekey_document2.imageset/Contents.json +1 -0
  16. package/ios/Resources/NativeListIcons.xcassets/onekey_document2.imageset/icon.svg +1 -0
  17. package/ios/Resources/NativeListIcons.xcassets/onekey_dot_ver_solid.imageset/Contents.json +1 -0
  18. package/ios/Resources/NativeListIcons.xcassets/onekey_dot_ver_solid.imageset/icon.svg +1 -0
  19. package/ios/Resources/NativeListIcons.xcassets/onekey_plus_large.imageset/Contents.json +1 -0
  20. package/ios/Resources/NativeListIcons.xcassets/onekey_plus_large.imageset/icon.svg +1 -0
  21. package/ios/Resources/NativeListIcons.xcassets/onekey_ticket.imageset/Contents.json +1 -0
  22. package/ios/Resources/NativeListIcons.xcassets/onekey_ticket.imageset/icon.svg +1 -0
  23. package/ios/Resources/NativeListIcons.xcassets/onekey_wallet.imageset/Contents.json +1 -0
  24. package/ios/Resources/NativeListIcons.xcassets/onekey_wallet.imageset/icon.svg +1 -0
  25. package/ios/tests/NativeListIdentityTitleIconLayoutTests.swift +81 -0
  26. package/ios/tests/NativeListLeadingVisualPairBackdropTests.swift +50 -0
  27. package/ios/tests/NativeListLifecycleTests.swift +60 -0
  28. package/lib/module/NativeList.web.js +9 -12
  29. package/lib/module/models.js +2 -0
  30. package/lib/module/validation.js +627 -114
  31. package/lib/module/web/NativeListWebEngine.js +49 -9
  32. package/lib/module/web/templates/IdentityRowRenderer.js +58 -4
  33. package/lib/typescript/src/models.d.ts +25 -14
  34. package/lib/typescript/src/validation.d.ts +7 -2
  35. package/package.json +3 -3
  36. package/src/NativeList.web.tsx +10 -14
  37. package/src/models.ts +144 -153
  38. package/src/validation.ts +1617 -167
  39. package/src/web/NativeListWebEngine.ts +62 -10
  40. package/src/web/templates/IdentityRowRenderer.ts +70 -4
@@ -54,6 +54,13 @@ internal class NativeListActivityRowView(context: ThemedReactContext, fonts: Nat
54
54
  details.onAction = { key, view, source, slot -> emitAction(key, view, source, slot) }
55
55
  }
56
56
 
57
+ /** Host clipping for the current bind; see [nativeListActivityHostClips]. */
58
+ private fun setHostClipping(clips: Boolean) {
59
+ content.clipChildren = clips
60
+ clipChildren = clips
61
+ clipToPadding = clips
62
+ }
63
+
57
64
  override fun defaultHeight(item: NativeListItem, layout: String) =
58
65
  if (item.json.has("amounts")) 0 else if ((item.json.optJSONArray("footerActions")?.length() ?: 0) > 0) 104 else 60
59
66
 
@@ -76,6 +83,10 @@ internal class NativeListActivityRowView(context: ThemedReactContext, fonts: Nat
76
83
  setPadding(hp, vp, hp, vp)
77
84
  visual.recycle()
78
85
  details.bind(item, theme, sourceScale)
86
+ // This view may be reused from a legacy row whose overhanging badge
87
+ // switched clipping off; a rich row has no badge on the host, so restore
88
+ // the defaults before returning.
89
+ setHostClipping(nativeListActivityHostClips(rich = true, leadingOverhangs = false))
79
90
  return
80
91
  }
81
92
  details.recycle()
@@ -157,6 +168,18 @@ internal class NativeListActivityRowView(context: ThemedReactContext, fonts: Nat
157
168
  sourceScale,
158
169
  data.optJSONObject("secondaryLeading"),
159
170
  )
171
+ // The token network badge hangs 4dp past the 40dp visual and Android clips a
172
+ // child to its own bounds while its parent clips children, so with the
173
+ // default clipping the history rows showed the badge cut flat (OK-66071).
174
+ // Stop clipping the content row and this row's padding exactly when the
175
+ // bound visual reports such a badge (SPEC "Leading badge overhang"), as the
176
+ // Identity renderer does.
177
+ setHostClipping(
178
+ nativeListActivityHostClips(
179
+ rich = false,
180
+ leadingOverhangs = !hideLeading && visual.requestsUnclippedHost,
181
+ )
182
+ )
160
183
  bind(
161
184
  title,
162
185
  data.optString("title"),
@@ -31,6 +31,7 @@ internal class NativeListIdentityRowView(context: ThemedReactContext, fonts: Nat
31
31
  private val titleLine = PackedTitleLineLayout(context)
32
32
  private val title = NativeListTextView(context)
33
33
  private val titleIcon = OneKeyIconView(context)
34
+ private var titleImage: NativeListImageSlot? = null
34
35
  private val subtitle = NativeListTextView(context)
35
36
  private val tertiary = NativeListTextView(context)
36
37
  private val badgeLine = NativeListTextView(context)
@@ -79,7 +80,7 @@ internal class NativeListIdentityRowView(context: ThemedReactContext, fonts: Nat
79
80
  "single"
80
81
  else item.json.optString("groupPosition")
81
82
 
82
- override val assetFields = listOf("leading")
83
+ override val assetFields = listOf("leading", "titleImage")
83
84
 
84
85
  override fun usesSourceScale(item: NativeListItem, provided: Boolean) =
85
86
  item.usesSelectorSourceScale || provided
@@ -262,6 +263,29 @@ internal class NativeListIdentityRowView(context: ThemedReactContext, fonts: Nat
262
263
  leadingActionIcon.useSourceScale = sourceScale
263
264
  leadingActionIcon.setOnClickListener(null)
264
265
  bindIdentity(item, theme, item.json.optBoolean("selected"), checkboxState)
266
+ val titleImageDescriptor = item.json.optJSONObject("titleImage")
267
+ val titleImageSource = titleImageDescriptor?.optJSONObject("image")
268
+ if (titleImageSource != null && titleImageSource.optString("uri").isNotEmpty()) {
269
+ if (item.json.optString("presentation") == "walletSidebar") {
270
+ titleLine.packsChildrenAtStart = true
271
+ title.layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
272
+ }
273
+ val slot = titleImage ?: NativeListImageSlot(reactContext).also { titleImage = it }
274
+ // Reinsert after icons so newly added suffixes cannot precede the image.
275
+ titleLine.removeView(slot.view)
276
+ titleLine.addView(slot.view, titleLine.indexOfChild(titleIcon) + 1)
277
+ val size = stylePx(titleImageSource.optDouble("width"))
278
+ slot.view.layoutParams = LayoutParams(size, size).apply { marginStart = dp(4) }
279
+ slot.view.visibility = VISIBLE
280
+ slot.view.contentDescription = titleImageDescriptor.optString("accessibilityLabel").takeIf(String::isNotEmpty)
281
+ slot.view.importantForAccessibility = if (slot.view.contentDescription == null)
282
+ View.IMPORTANT_FOR_ACCESSIBILITY_NO else View.IMPORTANT_FOR_ACCESSIBILITY_YES
283
+ slot.bind(titleImageSource, "${item.key}:titleImage", placeholder = "#00000000", round = true)
284
+ } else {
285
+ titleImage?.recycle()
286
+ titleImage?.view?.visibility = GONE
287
+ titleImage?.view?.contentDescription = null
288
+ }
265
289
  TextViewCompat.setLineHeight(
266
290
  title,
267
291
  dp(if (item.json.optString("presentation") == "walletSidebar") 16 else 24),
@@ -449,7 +473,10 @@ internal class NativeListIdentityRowView(context: ThemedReactContext, fonts: Nat
449
473
  val inner = (MeasureSpec.getSize(widthMeasureSpec) - paddingLeft - paddingRight)
450
474
  .coerceAtLeast(0)
451
475
  val leading = leadingFrame.layoutParams as LayoutParams
452
- val icon = if (titleIcon.visibility == VISIBLE) dp(24) else 0
476
+ val imageWidth = titleImage?.view?.takeIf { it.visibility == VISIBLE }?.let {
477
+ it.layoutParams.width + dp(4)
478
+ } ?: 0
479
+ val icon = (if (titleIcon.visibility == VISIBLE) dp(24) else 0) + imageWidth
453
480
  (inner * ratio).roundToInt()
454
481
  .minus(leading.width + leading.marginEnd + icon)
455
482
  .coerceAtLeast(0)
@@ -555,6 +582,9 @@ internal class NativeListIdentityRowView(context: ThemedReactContext, fonts: Nat
555
582
  longPressPoint = null
556
583
  longPressPointEpoch = -1L
557
584
  leadingFrame.recycle()
585
+ titleImage?.recycle()
586
+ titleImage?.view?.visibility = GONE
587
+ titleImage?.view?.contentDescription = null
558
588
  trailingColumn.reset()
559
589
  currentItem = null
560
590
  }
@@ -562,6 +592,7 @@ internal class NativeListIdentityRowView(context: ThemedReactContext, fonts: Nat
562
592
  override fun disposeContent() {
563
593
  recycleContent()
564
594
  leadingFrame.dispose()
595
+ titleImage?.dispose()
565
596
  }
566
597
 
567
598
  private fun bindIdentity(
@@ -878,19 +909,16 @@ internal class NativeListIdentityRowView(context: ThemedReactContext, fonts: Nat
878
909
  sourceScale,
879
910
  checkboxState,
880
911
  horizontal =
881
- (0 until accessories.length())
882
- .map { accessories.getJSONObject(it).optString("kind") }
883
- .let { kinds ->
884
- kinds.containsAll(listOf("value", "checkbox")) ||
885
- kinds == listOf("valuePair", "menu") ||
886
- kinds == listOf("valuePair", "chevron") ||
887
- (kinds == listOf("icon", "chevron") &&
888
- accessories.getJSONObject(0).optString("name") == "RefreshCcwOutline" &&
889
- accessories.getJSONObject(0).optString("actionKey").isEmpty()) ||
890
- (kinds == listOf("icon", "icon") &&
891
- accessories.getJSONObject(0).optString("name") == "PencilOutline" &&
892
- accessories.getJSONObject(1).optString("name") == "DragOutline")
893
- },
912
+ nativeListIdentityTrailingIsHorizontal(
913
+ (0 until accessories.length()).map { index ->
914
+ val accessory = accessories.getJSONObject(index)
915
+ NativeListTrailingDescriptor(
916
+ kind = accessory.optString("kind"),
917
+ name = accessory.optString("name"),
918
+ actionKey = accessory.optString("actionKey"),
919
+ )
920
+ }
921
+ ),
894
922
  )
895
923
  trailingColumn.endInset?.let { setPadding(paddingLeft, paddingTop, it, paddingBottom) }
896
924
  }
@@ -23,6 +23,8 @@ internal class NativeListLeadingVisual(private val reactContext: ThemedReactCont
23
23
  private val icon = OneKeyIconView(context)
24
24
  private val unread = View(context)
25
25
  private val networkBackdrop = View(context)
26
+ // Ring under the secondary token of an Activity pair (SPEC "Activity leading pair").
27
+ private val pairBackdrop = View(context)
26
28
  private val corner = OneKeyIconView(context)
27
29
  private val cornerBackdrop = View(context)
28
30
  private val slots = mutableListOf<NativeListImageSlot>()
@@ -32,6 +34,10 @@ internal class NativeListLeadingVisual(private val reactContext: ThemedReactCont
32
34
  private var visual = JSONObject()
33
35
  private var style = JSONObject()
34
36
  private var sources = emptyList<Pair<JSONObject, String>>()
37
+ // Roles inside [sources]: the token `networkImage` badge and the Activity
38
+ // `secondaryLeading` token; null when absent.
39
+ private var networkIndex: Int? = null
40
+ private var pairIndex: Int? = null
35
41
  val fallbackTextView: TextView
36
42
  get() = fallback
37
43
 
@@ -90,7 +96,9 @@ internal class NativeListLeadingVisual(private val reactContext: ThemedReactCont
90
96
  }
91
97
 
92
98
  init {
93
- listOf(fallback, icon, networkBackdrop, cornerBackdrop, corner, unread).forEach { addView(it) }
99
+ listOf(fallback, icon, pairBackdrop, networkBackdrop, cornerBackdrop, corner, unread).forEach {
100
+ addView(it)
101
+ }
94
102
  clipChildren = false
95
103
  clipToPadding = false
96
104
  }
@@ -119,6 +127,8 @@ internal class NativeListLeadingVisual(private val reactContext: ThemedReactCont
119
127
  "wallet" -> "avatar"
120
128
  else -> "generic"
121
129
  }
130
+ networkIndex = null
131
+ pairIndex = null
122
132
  sources =
123
133
  if (kind == "stackedImages")
124
134
  visual
@@ -132,7 +142,10 @@ internal class NativeListLeadingVisual(private val reactContext: ThemedReactCont
132
142
  else if (kind == "icon") emptyList()
133
143
  else visual.optJSONObject("image")?.let { listOf(it to variant) }.orEmpty()
134
144
  if (kind == "token" && sources.isNotEmpty())
135
- visual.optJSONObject("networkImage")?.let { sources = sources + (it to "network") }
145
+ visual.optJSONObject("networkImage")?.let {
146
+ networkIndex = sources.size
147
+ sources = sources + (it to "network")
148
+ }
136
149
  secondaryVisual?.let { second ->
137
150
  val kind = second.optString("kind")
138
151
  val source =
@@ -141,7 +154,10 @@ internal class NativeListLeadingVisual(private val reactContext: ThemedReactCont
141
154
  "icon" -> null
142
155
  else -> second.optJSONObject("image")
143
156
  }
144
- if (source != null)
157
+ if (source != null) {
158
+ // A second token forms a pair only next to a primary image; without one
159
+ // it simply takes the primary's place (OK-66071).
160
+ if (this.visual.optString("kind") == "token" && sources.isNotEmpty()) pairIndex = sources.size
145
161
  sources =
146
162
  sources +
147
163
  (source to
@@ -152,6 +168,7 @@ internal class NativeListLeadingVisual(private val reactContext: ThemedReactCont
152
168
  "wallet" -> "avatar"
153
169
  else -> "generic"
154
170
  })
171
+ }
155
172
  }
156
173
  while (slots.size < sources.size) {
157
174
  val slot = NativeListImageSlot(reactContext)
@@ -228,6 +245,7 @@ internal class NativeListLeadingVisual(private val reactContext: ThemedReactCont
228
245
  ) { loaded ->
229
246
  slot.view.visibility =
230
247
  if (!loaded && (index > 0 || sourceFallback)) INVISIBLE else VISIBLE
248
+ if (index == pairIndex) syncPairBackdrop()
231
249
  if (index == 0 && sourceFallback) {
232
250
  fallbackKey?.let {
233
251
  if (loaded) NativeListSourceFallbackState.forget(it)
@@ -242,9 +260,21 @@ internal class NativeListLeadingVisual(private val reactContext: ThemedReactCont
242
260
  }
243
261
  }
244
262
  }
245
- networkBackdrop.visibility = if (kind == "token" && sources.size > 1) VISIBLE else GONE
263
+ networkBackdrop.visibility = if (networkIndex != null) VISIBLE else GONE
246
264
  networkBackdrop.background =
247
265
  fill(color(theme?.optString("rowBackground", "#FFFFFF") ?: "#FFFFFF"), dp(10).toFloat())
266
+ pairBackdrop.background =
267
+ fill(color(theme?.optString("rowBackground", "#FFFFFF") ?: "#FFFFFF"), 0f)
268
+ syncPairBackdrop()
269
+ // Stacking order by role: primary, pair ring, secondary token, badge
270
+ // backdrop, badge, corner icon. Slots are reused across binds with
271
+ // different roles, so the order is restored on every bind.
272
+ pairBackdrop.bringToFront()
273
+ pairIndex?.let { slots[it].view.bringToFront() }
274
+ networkBackdrop.bringToFront()
275
+ networkIndex?.let { slots[it].view.bringToFront() }
276
+ cornerBackdrop.bringToFront()
277
+ corner.bringToFront()
248
278
  val badge = visual.optJSONObject("cornerIcon")
249
279
  corner.visibility = if (badge == null) GONE else VISIBLE
250
280
  cornerBackdrop.visibility = corner.visibility
@@ -353,7 +383,7 @@ internal class NativeListLeadingVisual(private val reactContext: ThemedReactCont
353
383
  (fallbackGlyph.parent as? android.view.ViewGroup)?.removeView(fallbackGlyph)
354
384
  frame.addView(fallbackGlyph, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
355
385
  }
356
- if (frame.parent == null) addView(frame)
386
+ if (frame.parent == null) addView(frame) else frame.bringToFront()
357
387
  overlays.add(frame to data)
358
388
  }
359
389
  previousOverlays.drop(descriptors.size).forEach { (frame, _) ->
@@ -426,28 +456,46 @@ internal class NativeListLeadingVisual(private val reactContext: ThemedReactCont
426
456
  iconSize,
427
457
  iconSize,
428
458
  )
429
- val tokenPair = kind == "token" && sources.size > 1
459
+ val ring = dp(2)
430
460
  slots.take(sources.size).forEachIndexed { index, slot ->
431
- val size =
432
- if (tokenPair && index == 1) dp(16)
433
- else if (sources.size == 1 || tokenPair) h else (h * 0.72f).roundToInt()
434
- val x =
435
- if (tokenPair && index == 1) w - size + dp(2)
436
- else if (sources.size == 1 || tokenPair) 0
437
- else (index * (w - size).toFloat() / maxOf(1, sources.size - 1)).roundToInt()
438
- val y = if (tokenPair && index == 1) h - size + dp(2) else (h - size) / 2
439
- val imageWidth = if (sources.size == 1 || tokenPair && index == 0) w else size
461
+ val frame =
462
+ nativeListLeadingSlotFrame(
463
+ index = index,
464
+ sourceCount = sources.size,
465
+ networkIndex = networkIndex,
466
+ pairIndex = pairIndex,
467
+ width = w,
468
+ height = h,
469
+ badgeSize = dp(16),
470
+ badgeOverhang = dp(2),
471
+ pairRing = ring,
472
+ )
440
473
  slot.view.measure(
441
- MeasureSpec.makeMeasureSpec(imageWidth, MeasureSpec.EXACTLY),
442
- MeasureSpec.makeMeasureSpec(size, MeasureSpec.EXACTLY),
474
+ MeasureSpec.makeMeasureSpec(frame.width, MeasureSpec.EXACTLY),
475
+ MeasureSpec.makeMeasureSpec(frame.height, MeasureSpec.EXACTLY),
443
476
  )
444
477
  val inset = if (index == 0) bitmapBorderWidth else 0
445
- place(slot.view, x + inset, y + inset, imageWidth - 2 * inset, size - 2 * inset)
478
+ place(
479
+ slot.view,
480
+ frame.x + inset,
481
+ frame.y + inset,
482
+ frame.width - 2 * inset,
483
+ frame.height - 2 * inset,
484
+ )
446
485
  val imageRadius =
447
486
  if (shape == "rounded" && !style.has("shape") && !style.has("cornerRadius"))
448
487
  dp(roundedImageRadius).toFloat()
449
488
  else radius
450
- val r = if (index == 0 && (sources.size == 1 || tokenPair)) imageRadius else size / 2f
489
+ val fillsFrame = index == 0 && pairIndex == null && (sources.size == 1 || networkIndex != null)
490
+ val r =
491
+ when {
492
+ fillsFrame -> imageRadius
493
+ // Pair members keep the token shape at their own size (rounded NFTs
494
+ // use the box's 10dp rule scaled to the member).
495
+ pairIndex != null && index != networkIndex && shape == "rounded" ->
496
+ minOf(dp(roundedImageRadius).toFloat(), frame.height / 4f)
497
+ else -> frame.height / 2f
498
+ }
451
499
  slot.view.outlineProvider =
452
500
  object : ViewOutlineProvider() {
453
501
  override fun getOutline(view: View, outline: Outline) {
@@ -458,6 +506,14 @@ internal class NativeListLeadingVisual(private val reactContext: ThemedReactCont
458
506
  }
459
507
  slot.view.clipToOutline = true
460
508
  }
509
+ if (pairIndex != null) {
510
+ val member = nativeListLeadingPairMemberSize(h)
511
+ val box = member + 2 * ring
512
+ (pairBackdrop.background as? GradientDrawable)?.cornerRadius =
513
+ if (shape == "rounded") minOf(dp(roundedImageRadius).toFloat(), member / 4f) + ring
514
+ else box / 2f
515
+ place(pairBackdrop, w - box, h - box, box, box)
516
+ }
461
517
  place(networkBackdrop, w - dp(16), h - dp(16), dp(20), dp(20))
462
518
  place(cornerBackdrop, w - dp(16), h - dp(16), dp(20), dp(20))
463
519
  place(corner, w - dp(15), h - dp(15), dp(18), dp(18))
@@ -487,9 +543,22 @@ internal class NativeListLeadingVisual(private val reactContext: ThemedReactCont
487
543
  }
488
544
  }
489
545
 
546
+ /**
547
+ * The pair ring sits above the primary token, so it is shown only while the
548
+ * secondary token image is actually displayed: hidden while that image loads,
549
+ * after a failed load and once the slot is recycled, or it would cover the
550
+ * primary's lower-right corner with a blank row-background disc.
551
+ */
552
+ private fun syncPairBackdrop() {
553
+ val secondary = pairIndex?.let { slots.getOrNull(it) }
554
+ pairBackdrop.visibility =
555
+ if (secondary != null && secondary.view.visibility == VISIBLE) VISIBLE else GONE
556
+ }
557
+
490
558
  fun recycle() {
491
559
  slots.forEach { it.recycle() }
492
560
  overlaySlots.forEach { it.recycle() }
561
+ pairBackdrop.visibility = GONE
493
562
  }
494
563
 
495
564
  fun dispose() {
@@ -4,6 +4,7 @@ import org.json.JSONArray
4
4
  import org.json.JSONObject
5
5
  import java.nio.ByteBuffer
6
6
  import java.security.MessageDigest
7
+ import kotlin.math.roundToInt
7
8
  import java.util.Locale
8
9
 
9
10
  internal data class NativeSelectionTarget(val scope: String, val key: String?)
@@ -48,6 +49,96 @@ internal fun nativeListLeadingVisualOverhangsFrame(
48
49
  overlayCount: Int,
49
50
  ): Boolean = kind == "token" && sourceCount > 1 || hasCornerIcon || overlayCount > 0
50
51
 
52
+ /**
53
+ * Whether an Activity row host clips its children and padding for this bind.
54
+ * One row view serves both the legacy row and the rich (`amounts`) row and is
55
+ * reused across binds, so clipping is derived from the bound item alone and
56
+ * applied on every bind: only a legacy row whose leading visual lays a badge
57
+ * past its frame ([nativeListLeadingVisualOverhangsFrame], OK-66071) opts out;
58
+ * a rich row keeps Android's default clipping even when the view it reuses
59
+ * last showed such a badge.
60
+ */
61
+ internal fun nativeListActivityHostClips(rich: Boolean, leadingOverhangs: Boolean): Boolean =
62
+ rich || !leadingOverhangs
63
+
64
+ internal data class NativeListSlotFrame(val x: Int, val y: Int, val width: Int, val height: Int)
65
+
66
+ /** Side of one member of an Activity token pair inside a [frame]-unit box (24 in 40). */
67
+ internal fun nativeListLeadingPairMemberSize(frame: Int): Int = (frame * 0.6f).roundToInt()
68
+
69
+ /**
70
+ * Frame of image source [index] of a leading visual inside its [width]×[height]
71
+ * box (SPEC "Activity leading pair", OK-66071). [networkIndex] is the token
72
+ * `networkImage` badge and [pairIndex] the Activity `secondaryLeading` token;
73
+ * either is null when absent. A pair draws the primary at the top-left and the
74
+ * secondary at the bottom-right, both [nativeListLeadingPairMemberSize] wide and
75
+ * the secondary inset by its [pairRing]; the badge keeps its corner geometry
76
+ * above them. A lone token, or a token with only a badge, fills the box. Any
77
+ * other multi-source visual (stackedImages) steps 0.72-sized images diagonally.
78
+ */
79
+ internal fun nativeListLeadingSlotFrame(
80
+ index: Int,
81
+ sourceCount: Int,
82
+ networkIndex: Int?,
83
+ pairIndex: Int?,
84
+ width: Int,
85
+ height: Int,
86
+ badgeSize: Int,
87
+ badgeOverhang: Int,
88
+ pairRing: Int,
89
+ ): NativeListSlotFrame {
90
+ if (index == networkIndex)
91
+ return NativeListSlotFrame(
92
+ width - badgeSize + badgeOverhang,
93
+ height - badgeSize + badgeOverhang,
94
+ badgeSize,
95
+ badgeSize,
96
+ )
97
+ if (pairIndex != null) {
98
+ val member = nativeListLeadingPairMemberSize(height)
99
+ return if (index == pairIndex)
100
+ NativeListSlotFrame(width - member - pairRing, height - member - pairRing, member, member)
101
+ else NativeListSlotFrame(0, 0, member, member)
102
+ }
103
+ if (sourceCount == 1 || networkIndex != null) return NativeListSlotFrame(0, 0, width, height)
104
+ val side = (height * 0.72f).roundToInt()
105
+ val x = (index * (width - side).toFloat() / maxOf(1, sourceCount - 1)).roundToInt()
106
+ return NativeListSlotFrame(x, (height - side) / 2, side, side)
107
+ }
108
+
109
+ internal data class NativeListTrailingDescriptor(
110
+ val kind: String,
111
+ val name: String = "",
112
+ val actionKey: String = "",
113
+ )
114
+
115
+ /**
116
+ * Identity trailing accessories stack vertically unless they form one of the
117
+ * pairs every platform lays out side by side. The `valuePair` + `DotVerSolid`
118
+ * menu icon pair (the Swap token selector's balance and row menu) matches
119
+ * iOS `configureTrailingStack`; stacking it put the menu under the balance and
120
+ * clipped both inside the fixed row height on Android only.
121
+ */
122
+ internal fun nativeListIdentityTrailingIsHorizontal(
123
+ accessories: List<NativeListTrailingDescriptor>,
124
+ ): Boolean {
125
+ val kinds = accessories.map { it.kind }
126
+ if (kinds.containsAll(listOf("value", "checkbox"))) return true
127
+ if (accessories.size != 2) return false
128
+ val (first, second) = accessories
129
+ return kinds == listOf("valuePair", "menu") ||
130
+ kinds == listOf("valuePair", "chevron") ||
131
+ (kinds == listOf("valuePair", "icon") &&
132
+ second.name == "DotVerSolid" &&
133
+ second.actionKey == "menu") ||
134
+ (kinds == listOf("icon", "chevron") &&
135
+ first.name == "RefreshCcwOutline" &&
136
+ first.actionKey.isEmpty()) ||
137
+ (kinds == listOf("icon", "icon") &&
138
+ first.name == "PencilOutline" &&
139
+ second.name == "DragOutline")
140
+ }
141
+
51
142
  internal fun resolveNativeListSelectorBackgroundColor(
52
143
  rowType: String,
53
144
  styleBackgroundColor: String?,
@@ -287,7 +378,9 @@ internal fun mergeRow(original: JSONObject, changes: JSONObject): JSONObject {
287
378
  val keys = changes.keys()
288
379
  while (keys.hasNext()) {
289
380
  val key = keys.next()
290
- if (key != "key" && key != "type") result.put(key, changes.get(key))
381
+ if (key != "key" && key != "type") {
382
+ if (changes.isNull(key)) result.remove(key) else result.put(key, changes.get(key))
383
+ }
291
384
  }
292
385
  return result
293
386
  }
@@ -287,6 +287,12 @@ internal class OneKeyIconView(context: android.content.Context) : View(context)
287
287
  // use EVEN_ODD. Other existing icons retain their prior rendering behavior.
288
288
  private val actionIconFillTypes =
289
289
  mapOf(
290
+ // Asset-list icons the app passes by name (OK-66072 audit): absent entries drew nothing.
291
+ "DotVerSolid" to listOf(Path.FillType.WINDING),
292
+ "Document2Outline" to listOf(Path.FillType.WINDING, Path.FillType.EVEN_ODD),
293
+ "WalletOutline" to listOf(Path.FillType.WINDING, Path.FillType.EVEN_ODD),
294
+ "PlusLargeOutline" to listOf(Path.FillType.WINDING),
295
+ "TicketOutline" to listOf(Path.FillType.WINDING, Path.FillType.EVEN_ODD),
290
296
  "GlobusOutline" to listOf(Path.FillType.EVEN_ODD),
291
297
  "GasSolid" to listOf(Path.FillType.EVEN_ODD),
292
298
  "RefreshCcwOutline" to listOf(Path.FillType.WINDING),
@@ -333,6 +339,29 @@ internal class OneKeyIconView(context: android.content.Context) : View(context)
333
339
  // Exact 24x24 paths from app-monorepo packages/components Icon sources.
334
340
  private val iconPaths =
335
341
  mapOf(
342
+ "DotVerSolid" to
343
+ listOf(
344
+ "M14 18v4h-4v-4zm0-4h-4v-4h4zm0-8h-4V2h4z"
345
+ ),
346
+ "Document2Outline" to
347
+ listOf(
348
+ "M12 16H8v-2h4zm4-4H8v-2h8zm0-4H8V6h8z",
349
+ "M20 22H4V2h16zM6 20h12V4H6z"
350
+ ),
351
+ "WalletOutline" to
352
+ listOf(
353
+ "M15.5 13.25a1.25 1.25 0 1 1 0 2.5 1.25 1.25 0 0 1 0-2.5",
354
+ "M17 8h4v13H7a4 4 0 0 1-4-4V6.5A3.5 3.5 0 0 1 6.5 3H17zM5 17a2 2 0 0 0 2 2h12v-9H6.5c-.537 0-1.045-.12-1.5-.337zM6.5 5a1.5 1.5 0 1 0 0 3H15V5z"
355
+ ),
356
+ "PlusLargeOutline" to
357
+ listOf(
358
+ "M13 11h8v2h-8v8h-2v-8H3v-2h8V3h2z"
359
+ ),
360
+ "TicketOutline" to
361
+ listOf(
362
+ "M16 17.01h-2V15h2zm0-4h-2V11h2zm0-4h-2V7h2z",
363
+ "m22 9.927-.629.252c-1.661.665-1.661 2.977 0 3.642l.629.252V20H2v-5.927l.629-.252c1.661-.665 1.661-2.977 0-3.642L2 9.927V4h20zM4 8.636c2.5 1.52 2.499 5.207 0 6.727V18h16v-2.637c-2.499-1.52-2.5-5.208 0-6.727V6H4z"
364
+ ),
336
365
  "RefreshCcwOutline" to
337
366
  listOf(
338
367
  "M5 12a7 7 0 0 0 7 7 7.15 7.15 0 0 0 4.955-2h-2.943v-2h6v6h-2v-2.257A9.148 9.148 0 0 1 12 21a9 9 0 0 1-9-9v-1h2v1Zm1-6.754A9.148 9.148 0 0 1 12 3a9 9 0 0 1 9 9v1h-2v-1a7 7 0 0 0-7-7 7.15 7.15 0 0 0-4.955 2H10v2H4V3h2v2.246Z"
@@ -0,0 +1,29 @@
1
+ package com.margelo.nitro.nativelist
2
+
3
+ import org.junit.Assert.assertEquals
4
+ import org.junit.Test
5
+
6
+ class NativeListActivityHostClipPolicyTest {
7
+ // One Activity row view serves legacy and rich rows and is reused across
8
+ // binds. A legacy row with an overhanging network badge switches the host's
9
+ // clipping off (OK-66071); the next bind must derive clipping afresh, so a
10
+ // rich row reusing that view clips again instead of drawing its details over
11
+ // a neighbouring row (PR #185 review).
12
+ @Test
13
+ fun rebindSequenceDerivesClippingFromEachBoundItem() {
14
+ data class Bind(val name: String, val rich: Boolean, val leadingOverhangs: Boolean, val clips: Boolean)
15
+
16
+ val binds = listOf(
17
+ Bind("legacy row, plain token", rich = false, leadingOverhangs = false, clips = true),
18
+ Bind("legacy row, network badge", rich = false, leadingOverhangs = true, clips = false),
19
+ Bind("rich row reusing the badge row's view", rich = true, leadingOverhangs = false, clips = true),
20
+ Bind("legacy row, badge again", rich = false, leadingOverhangs = true, clips = false),
21
+ Bind("legacy row, hidden leading", rich = false, leadingOverhangs = false, clips = true),
22
+ )
23
+
24
+ assertEquals(
25
+ binds.map(Bind::clips),
26
+ binds.map { nativeListActivityHostClips(rich = it.rich, leadingOverhangs = it.leadingOverhangs) },
27
+ )
28
+ }
29
+ }
@@ -0,0 +1,36 @@
1
+ package com.margelo.nitro.nativelist
2
+
3
+ import org.junit.Assert.assertEquals
4
+ import org.junit.Test
5
+
6
+ class NativeListIdentityTrailingAxisTest {
7
+ // Identity trailing accessories stack vertically except for the pairs iOS
8
+ // lays out side by side. The Swap token selector's `valuePair` + DotVerSolid
9
+ // menu icon was missing from the Android list, so the balance was pushed
10
+ // above the menu and clipped inside the 60dp row.
11
+ @Test
12
+ fun matchesTheSideBySidePairsOfIos() {
13
+ fun d(kind: String, name: String = "", actionKey: String = "") =
14
+ NativeListTrailingDescriptor(kind, name, actionKey)
15
+
16
+ val cases: List<Pair<List<NativeListTrailingDescriptor>, Boolean>> = listOf(
17
+ listOf(d("valuePair")) to false,
18
+ listOf(d("valuePair"), d("menu", actionKey = "menu")) to true,
19
+ listOf(d("valuePair"), d("icon", "DotVerSolid", "menu")) to true,
20
+ listOf(d("valuePair"), d("icon", "DotVerSolid")) to false,
21
+ listOf(d("valuePair"), d("icon", "InfoCircleOutline", "menu")) to false,
22
+ listOf(d("icon", "DotVerSolid", "menu"), d("valuePair")) to false,
23
+ listOf(d("valuePair"), d("chevron")) to true,
24
+ listOf(d("icon", "RefreshCcwOutline"), d("chevron")) to true,
25
+ listOf(d("icon", "RefreshCcwOutline", "refresh"), d("chevron")) to false,
26
+ listOf(d("icon", "PencilOutline", "edit"), d("icon", "DragOutline")) to true,
27
+ listOf(d("value"), d("checkbox")) to true,
28
+ listOf(d("valuePair"), d("icon", "DotVerSolid", "menu"), d("chevron")) to false,
29
+ )
30
+
31
+ assertEquals(
32
+ cases.map { it.second },
33
+ cases.map { nativeListIdentityTrailingIsHorizontal(it.first) },
34
+ )
35
+ }
36
+ }
@@ -0,0 +1,68 @@
1
+ package com.margelo.nitro.nativelist
2
+
3
+ import org.junit.Assert.assertEquals
4
+ import org.junit.Test
5
+
6
+ class NativeListLeadingSlotFrameTest {
7
+ // 40dp box, 16dp badge hanging 2dp past the frame, 2dp pair ring.
8
+ private fun frame(index: Int, count: Int, network: Int? = null, pair: Int? = null) =
9
+ nativeListLeadingSlotFrame(
10
+ index = index,
11
+ sourceCount = count,
12
+ networkIndex = network,
13
+ pairIndex = pair,
14
+ width = 40,
15
+ height = 40,
16
+ badgeSize = 16,
17
+ badgeOverhang = 2,
18
+ pairRing = 2,
19
+ )
20
+
21
+ private val full = NativeListSlotFrame(0, 0, 40, 40)
22
+ private val badge = NativeListSlotFrame(26, 26, 16, 16)
23
+ private val primary = NativeListSlotFrame(0, 0, 24, 24)
24
+ private val secondary = NativeListSlotFrame(14, 14, 24, 24)
25
+
26
+ @Test
27
+ fun loneTokenFillsTheBox() {
28
+ assertEquals(full, frame(0, 1))
29
+ }
30
+
31
+ @Test
32
+ fun tokenWithNetworkBadgeKeepsTheCornerBadge() {
33
+ assertEquals(listOf(full, badge), listOf(frame(0, 2, network = 1), frame(1, 2, network = 1)))
34
+ }
35
+
36
+ // OK-66071: the history swap rows bind [send, network, receive]. Android laid
37
+ // the receive token over the whole box, hiding the send token and the network
38
+ // badge; iOS drew it in the badge's corner slot over the badge.
39
+ @Test
40
+ fun activityPairStepsTheSecondaryToTheBottomRightUnderTheBadge() {
41
+ assertEquals(
42
+ listOf(primary, badge, secondary),
43
+ (0 until 3).map { frame(it, 3, network = 1, pair = 2) },
44
+ )
45
+ }
46
+
47
+ @Test
48
+ fun activityPairWithoutNetworkBadge() {
49
+ assertEquals(listOf(primary, secondary), listOf(frame(0, 2, pair = 1), frame(1, 2, pair = 1)))
50
+ }
51
+
52
+ @Test
53
+ fun pairMemberIsSixTenthsOfTheBox() {
54
+ assertEquals(listOf(24, 19, 29), listOf(40, 32, 48).map(::nativeListLeadingPairMemberSize))
55
+ }
56
+
57
+ @Test
58
+ fun stackedImagesStepDiagonally() {
59
+ assertEquals(
60
+ listOf(
61
+ NativeListSlotFrame(0, 5, 29, 29),
62
+ NativeListSlotFrame(6, 5, 29, 29),
63
+ NativeListSlotFrame(11, 5, 29, 29),
64
+ ),
65
+ (0 until 3).map { frame(it, 3) },
66
+ )
67
+ }
68
+ }
@@ -59,6 +59,10 @@ separately from source implementation in STYLE_SPEC §9.
59
59
  a 4-point gap. It has no click action; an optional Web `hoverActionKey`
60
60
  emits an icon-anchored tooltip request on hover or touch. A trailing `button` is the fixed small
61
61
  secondary text action and uses the normal accessory event slot.
62
+ - **Title image:** optional `titleImage` renders a circular image after the title
63
+ icons and before suffixes/badges. Its `ImageSource` dimensions must be square,
64
+ 1...40 logical units. Optional Web `hoverActionKey` uses the existing anchored
65
+ help event; native keeps the image decorative for touch. See SPEC.md.
62
66
  - **Second Web help icon:** `secondaryTitleIcon` uses the same descriptor after
63
67
  `titleIcon`; each glyph has its own hover/touch anchor. Native ignores it.
64
68
  - **Decorative refresh:** a trailing `RefreshCcwOutline` without `actionKey`