craft-native 0.0.254 → 0.0.272

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 (31) hide show
  1. package/README.md +22 -0
  2. package/dist/android/src/index.d.ts +4 -2
  3. package/dist/android/src/index.js +65 -4
  4. package/dist/android/templates/AndroidManifest.xml.template +1 -0
  5. package/dist/android/templates/CraftHealthServices.kt.template +132 -0
  6. package/dist/android/templates/CraftHealthServicesService.kt.template +217 -0
  7. package/dist/android/templates/CraftHealthServicesStub.kt.template +16 -0
  8. package/dist/android/templates/CraftNativeHealth.kt.template +115 -0
  9. package/dist/android/templates/CraftNativeHealthStub.kt.template +1 -0
  10. package/dist/android/templates/CraftNativeMutation.kt.template +2 -0
  11. package/dist/android/templates/CraftNativeServices.kt.template +31 -8
  12. package/dist/android/templates/CraftNativeShell.kt.template +5 -0
  13. package/dist/android/templates/MainActivityNative.kt.template +343 -33
  14. package/dist/android/templates/build.gradle.kts.app.template +5 -0
  15. package/dist/api/mobile.d.ts +9 -1
  16. package/dist/browser/lifecycle.d.ts +7 -0
  17. package/dist/browser.js +126 -24
  18. package/dist/cli.js +1 -1
  19. package/dist/components/index.d.ts +19 -10
  20. package/dist/index.cjs +1 -1
  21. package/dist/index.js +1 -1
  22. package/dist/ios/templates/CraftApp.swift +55 -7
  23. package/dist/ios/templates/CraftHealth.swift +38 -0
  24. package/dist/ios/templates/CraftHybrid.swift +50 -7
  25. package/dist/ios/templates/CraftNativeActions.swift +109 -22
  26. package/dist/ios/templates/CraftNativeFlatList.swift +187 -18
  27. package/dist/ios/templates/CraftNativeMutation.swift +3 -1
  28. package/dist/ios/templates/CraftNativeScreen.swift +466 -24
  29. package/dist/macos/templates/CraftNativeMutation.swift +3 -1
  30. package/dist/mobile.js +1 -1
  31. package/package.json +1 -1
@@ -226,6 +226,8 @@ private data class CraftNativeLayoutStyle(
226
226
  val bottom: Int? = null,
227
227
  val left: Int? = null,
228
228
  val alignSelf: String? = null,
229
+ /** CSS/Yoga order; Kotlin's stable sort preserves source order for ties. */
230
+ val order: Int = 0,
229
231
  val flexGrow: Double = 0.0,
230
232
  val flexShrink: Double? = null,
231
233
  val flexBasis: CraftNativeLength? = null,
@@ -252,6 +254,7 @@ private data class CraftNativeLayoutStyle(
252
254
  position = craftNativeOptionalString(raw.opt("position")) ?: "relative",
253
255
  top = number("top"), right = number("right"), bottom = number("bottom"), left = number("left"),
254
256
  alignSelf = craftNativeOptionalString(raw.opt("alignSelf"))?.takeUnless { it == "auto" },
257
+ order = decimal("order")?.toInt() ?: 0,
255
258
  flexGrow = decimal("flexGrow") ?: flex?.coerceAtLeast(0.0) ?: 0.0,
256
259
  flexShrink = decimal("flexShrink") ?: flex?.let { if (it != 0.0) 1.0 else 0.0 },
257
260
  flexBasis = length("flexBasis")
@@ -348,10 +351,131 @@ private class CraftNativeSvgView(context: Context) : View(context) {
348
351
  var drawing: JSONObject = JSONObject()
349
352
  set(value) {
350
353
  field = value
354
+ animates = hasAnimation(value.optJSONArray("children"))
351
355
  invalidate()
352
356
  requestLayout()
357
+ updateFrames()
353
358
  }
354
359
 
360
+ /** Whether a shape carries keyframes (SMIL `<animate attributeName="d">`, stx's `Animate`). */
361
+ private var animates = false
362
+ /** When this drawing's repetition began (System.nanoTime), 0 while still. */
363
+ private var animationStart = 0L
364
+ private var frameScheduled = false
365
+ private val frameCallback = android.view.Choreographer.FrameCallback { nextFrame() }
366
+
367
+ /** Moving now: shown, and animations are on (off is Android's reduced motion). */
368
+ val isAnimating: Boolean
369
+ get() = animates && isAttachedToWindow && isShown && ValueAnimator.areAnimatorsEnabled()
370
+
371
+ override fun onAttachedToWindow() {
372
+ super.onAttachedToWindow()
373
+ updateFrames()
374
+ }
375
+
376
+ override fun onDetachedFromWindow() {
377
+ super.onDetachedFromWindow()
378
+ updateFrames()
379
+ }
380
+
381
+ override fun onVisibilityChanged(changedView: View, visibility: Int) {
382
+ super.onVisibilityChanged(changedView, visibility)
383
+ updateFrames()
384
+ }
385
+
386
+ /** One frame at a time from the display (Choreographer) while moving; none while still or away. */
387
+ private fun updateFrames() {
388
+ if (isAnimating) {
389
+ if (animationStart == 0L) animationStart = System.nanoTime()
390
+ if (!frameScheduled) {
391
+ frameScheduled = true
392
+ android.view.Choreographer.getInstance().postFrameCallback(frameCallback)
393
+ }
394
+ } else {
395
+ animationStart = 0L
396
+ if (frameScheduled) {
397
+ frameScheduled = false
398
+ android.view.Choreographer.getInstance().removeFrameCallback(frameCallback)
399
+ }
400
+ }
401
+ }
402
+
403
+ private fun nextFrame() {
404
+ frameScheduled = false
405
+ if (!isAnimating) return updateFrames()
406
+ // Drawn only while some of it is on screen; a row scrolled away keeps its time.
407
+ if (getLocalVisibleRect(android.graphics.Rect())) invalidate()
408
+ frameScheduled = true
409
+ android.view.Choreographer.getInstance().postFrameCallback(frameCallback)
410
+ }
411
+
412
+ private fun hasAnimation(children: JSONArray?): Boolean {
413
+ if (children == null) return false
414
+ return (0 until children.length()).any { index ->
415
+ val node = children.optJSONObject(index) ?: return@any false
416
+ if (node.optString("type") == "Animate") node.optJSONObject("props")?.has("frames") == true
417
+ else hasAnimation(node.optJSONArray("children"))
418
+ }
419
+ }
420
+
421
+ /** A shape's outline now: between its keyframes while moving, else its own. */
422
+ private fun outline(node: JSONObject): List<Float>? {
423
+ if (isAnimating && animationStart != 0L) {
424
+ val children = node.optJSONArray("children")
425
+ val animate = children?.let { list -> (0 until list.length()).mapNotNull { list.optJSONObject(it) }.firstOrNull { it.optString("type") == "Animate" } }
426
+ animate?.optJSONObject("props")?.let { keyframe(it, (System.nanoTime() - animationStart) / 1e9) }?.let { return it }
427
+ }
428
+ val geometry = node.optJSONObject("props")?.optJSONArray("geometry") ?: return null
429
+ return (0 until geometry.length()).map { craftNativeNumber(geometry.opt(it))?.toFloat() ?: Float.NaN }
430
+ }
431
+
432
+ companion object {
433
+ /**
434
+ * The outline `elapsed` seconds into an `Animate`'s repetitions, between
435
+ * the keyframes either side of now (SMIL's linear `calcMode`); null when
436
+ * it does not animate path data or its repetitions are over.
437
+ */
438
+ fun keyframe(props: JSONObject, elapsed: Double): List<Float>? {
439
+ if (props.optString("attributeName") != "d" || elapsed < 0) return null
440
+ val duration = craftNativeNumber(props.opt("duration"))?.takeIf { it.isFinite() && it > 0 } ?: return null
441
+ val raw = props.optJSONArray("frames") ?: return null
442
+ val frames = (0 until raw.length()).mapNotNull { index ->
443
+ raw.optJSONArray(index)?.let { frame -> (0 until frame.length()).map { craftNativeNumber(frame.opt(it))?.toFloat() ?: Float.NaN } }
444
+ }
445
+ if (frames.size < 2 || frames.size != raw.length()) return null
446
+ craftNativeNumber(props.opt("repeatCount"))?.let { if (it.isFinite() && elapsed >= it * duration) return null }
447
+ val given = props.optJSONArray("frameTimes")?.let { times -> (0 until times.length()).mapNotNull { craftNativeNumber(times.opt(it)) } }.orEmpty()
448
+ val times = if (given.size == frames.size) given else frames.indices.map { it.toDouble() / (frames.size - 1) }
449
+ val phase = (elapsed % duration) / duration
450
+ var index = 0
451
+ while (index < frames.size - 2 && times[index + 1] <= phase) index++
452
+ val span = times[index + 1] - times[index]
453
+ val share = if (span > 0) ((phase - times[index]) / span).coerceIn(0.0, 1.0).toFloat() else 0f
454
+ return interpolate(frames[index], frames[index + 1], share)
455
+ }
456
+
457
+ /** Two outlines with the same commands, a share of the way between; the first when they differ. */
458
+ fun interpolate(from: List<Float>, to: List<Float>, share: Float): List<Float> {
459
+ if (from.size != to.size || share <= 0f) return from
460
+ val out = from.toMutableList()
461
+ var index = 0
462
+ while (index < from.size) {
463
+ if (to[index] != from[index]) return from
464
+ val count = when (from[index].toInt()) {
465
+ 0, 1 -> 2
466
+ 2 -> 6
467
+ 3 -> 4
468
+ 4 -> 0
469
+ else -> return from
470
+ }
471
+ if (index + count >= from.size) return from
472
+ for (offset in 1..count) out[index + offset] = from[index + offset] + (to[index + offset] - from[index + offset]) * share
473
+ index += count + 1
474
+ }
475
+ return out
476
+ }
477
+ }
478
+
355
479
  private data class SvgPaint(
356
480
  val fill: String? = "#000000",
357
481
  val stroke: String? = null,
@@ -486,25 +610,25 @@ private class CraftNativeSvgView(context: Context) : View(context) {
486
610
  when (node.optString("type")) {
487
611
  "G" -> drawNodes(canvas, node.optJSONArray("children") ?: JSONArray(), paint)
488
612
  "SvgText" -> drawText(canvas, node, paint)
613
+ "Animate" -> Unit
489
614
  else -> drawPath(canvas, node, paint)
490
615
  }
491
616
  }
492
617
  }
493
618
 
494
619
  private fun drawPath(canvas: Canvas, node: JSONObject, style: SvgPaint) {
495
- val props = node.optJSONObject("props") ?: JSONObject()
496
- val geometry = props.optJSONArray("geometry") ?: return
497
- if ((0 until geometry.length()).any { craftNativeNumber(geometry.opt(it))?.toFloat()?.isFinite() != true }) return
620
+ val geometry = outline(node) ?: return
621
+ if (geometry.any { !it.isFinite() }) return
498
622
  val path = Path()
499
623
  var index = 0
500
- while (index < geometry.length()) {
501
- val command = craftNativeNumber(geometry.opt(index))?.toInt() ?: break
502
- fun number(offset: Int): Float = craftNativeNumber(geometry.opt(index + offset))?.toFloat() ?: Float.NaN
624
+ while (index < geometry.size) {
625
+ val command = geometry[index].toInt()
626
+ fun number(offset: Int): Float = geometry[index + offset]
503
627
  when (command) {
504
- 0 -> if (index + 2 < geometry.length()) { path.moveTo(number(1), number(2)); index += 3 } else break
505
- 1 -> if (index + 2 < geometry.length()) { path.lineTo(number(1), number(2)); index += 3 } else break
506
- 2 -> if (index + 6 < geometry.length()) { path.cubicTo(number(1), number(2), number(3), number(4), number(5), number(6)); index += 7 } else break
507
- 3 -> if (index + 4 < geometry.length()) { path.quadTo(number(1), number(2), number(3), number(4)); index += 5 } else break
628
+ 0 -> if (index + 2 < geometry.size) { path.moveTo(number(1), number(2)); index += 3 } else break
629
+ 1 -> if (index + 2 < geometry.size) { path.lineTo(number(1), number(2)); index += 3 } else break
630
+ 2 -> if (index + 6 < geometry.size) { path.cubicTo(number(1), number(2), number(3), number(4), number(5), number(6)); index += 7 } else break
631
+ 3 -> if (index + 4 < geometry.size) { path.quadTo(number(1), number(2), number(3), number(4)); index += 5 } else break
508
632
  4 -> { path.close(); index += 1 }
509
633
  else -> break
510
634
  }
@@ -759,6 +883,42 @@ internal class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
759
883
  private var safeAreaPadding = intArrayOf(0, 0, 0, 0)
760
884
  private var applyingPadding = false
761
885
  private var safeAreaEdges = setOf("top", "right", "bottom", "left")
886
+ private var shieldedModalTouch = false
887
+ private var dismissibleModalTouch: CraftNativeModalView? = null
888
+
889
+ override fun dispatchTouchEvent(event: MotionEvent): Boolean {
890
+ if (event.actionMasked == MotionEvent.ACTION_DOWN) {
891
+ val x = event.x.roundToInt()
892
+ val y = event.y.roundToInt()
893
+ dismissibleModalTouch = (childCount - 1 downTo 0).asSequence()
894
+ .map { getChildAt(it) }
895
+ .filterIsInstance<CraftNativeModalView>()
896
+ .firstOrNull { child ->
897
+ child.dismissOnOutsideTap && child.visibility == View.VISIBLE && child.alpha > 0.01f
898
+ && (x < child.left || x >= child.right || y < child.top || y >= child.bottom)
899
+ }
900
+ shieldedModalTouch = (childCount - 1 downTo 0).any { index ->
901
+ val child = getChildAt(index)
902
+ child is CraftNativeModalView && !child.blocksBackground && child.visibility == View.VISIBLE
903
+ && (x < child.left || x >= child.right || y < child.top || y >= child.bottom)
904
+ && child.hasBlockingModalBelow()
905
+ }
906
+ }
907
+ dismissibleModalTouch?.let { modal ->
908
+ if (event.actionMasked == MotionEvent.ACTION_UP) {
909
+ modal.requestClose(reason = "backdrop")
910
+ dismissibleModalTouch = null
911
+ } else if (event.actionMasked == MotionEvent.ACTION_CANCEL) {
912
+ dismissibleModalTouch = null
913
+ }
914
+ return true
915
+ }
916
+ if (shieldedModalTouch) {
917
+ if (event.actionMasked == MotionEvent.ACTION_UP || event.actionMasked == MotionEvent.ACTION_CANCEL) shieldedModalTouch = false
918
+ return true
919
+ }
920
+ return super.dispatchTouchEvent(event)
921
+ }
762
922
 
763
923
  override fun setPadding(left: Int, top: Int, right: Int, bottom: Int) {
764
924
  if (!applyingPadding) authoredPadding = intArrayOf(left, top, right, bottom)
@@ -991,6 +1151,11 @@ internal class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
991
1151
  invalidateMeasurementAncestors(parent)
992
1152
  }
993
1153
 
1154
+ /** Flex/grid order with Kotlin's stable sort retaining source order for ties. */
1155
+ private fun orderedChildIndices(): List<Int> = (0 until childCount).sortedWith(
1156
+ compareBy<Int> { styles[getChildAt(it)]?.order ?: 0 }.thenBy { it },
1157
+ )
1158
+
994
1159
  private data class Entry(val view: View, val style: CraftNativeLayoutStyle, val width: Int, val height: Int)
995
1160
  private data class Line(val entries: MutableList<Entry> = mutableListOf(), var main: Float = 0f, var cross: Float = 0f)
996
1161
  private data class GridLayout(val rows: List<List<Entry>>, val absolute: List<Entry>, val cellWidth: Float, val rowHeights: List<Float>)
@@ -1002,7 +1167,7 @@ internal class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
1002
1167
  val lines = mutableListOf<Line>()
1003
1168
  val absolute = mutableListOf<Entry>()
1004
1169
  val pending = mutableListOf<Entry>()
1005
- for (index in 0 until childCount) {
1170
+ for (index in orderedChildIndices()) {
1006
1171
  val child = getChildAt(index)
1007
1172
  if (child.visibility == GONE) continue
1008
1173
  val style = styles[child] ?: CraftNativeLayoutStyle()
@@ -1010,13 +1175,25 @@ internal class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
1010
1175
  val horizontalMargins = (lp?.leftMargin ?: 0) + (lp?.rightMargin ?: 0)
1011
1176
  val verticalMargins = (lp?.topMargin ?: 0) + (lp?.bottomMargin ?: 0)
1012
1177
  if (child is CraftNativeModalView) {
1013
- val modalWidth = max(0, width - paddingLeft - paddingRight)
1014
- val modalHeight = max(0, height - paddingTop - paddingBottom)
1015
- child.measure(
1016
- View.MeasureSpec.makeMeasureSpec(modalWidth, View.MeasureSpec.EXACTLY),
1017
- View.MeasureSpec.makeMeasureSpec(modalHeight, View.MeasureSpec.EXACTLY),
1018
- )
1019
- absolute += Entry(child, style, modalWidth, modalHeight)
1178
+ if (child.blocksBackground) {
1179
+ val modalWidth = max(0, width - paddingLeft - paddingRight)
1180
+ val modalHeight = max(0, height - paddingTop - paddingBottom)
1181
+ child.measure(
1182
+ View.MeasureSpec.makeMeasureSpec(modalWidth, View.MeasureSpec.EXACTLY),
1183
+ View.MeasureSpec.makeMeasureSpec(modalHeight, View.MeasureSpec.EXACTLY),
1184
+ )
1185
+ absolute += Entry(child, style, modalWidth, modalHeight)
1186
+ } else {
1187
+ // A non-modal popup is still promoted to the screen root,
1188
+ // but its authored absolute style controls its viewport
1189
+ // frame instead of turning it into a full-screen child.
1190
+ // The root content box is the same screenViewport origin
1191
+ // returned by View.measure; safe-area padding is not
1192
+ // injected a second time here.
1193
+ val childWidth = measureChildWidth(child, style, width - paddingLeft - paddingRight, horizontalMargins)
1194
+ val childHeight = measureChildHeight(child, style, height - paddingTop - paddingBottom, verticalMargins)
1195
+ absolute += Entry(child, style, childWidth, childHeight)
1196
+ }
1020
1197
  continue
1021
1198
  }
1022
1199
  val childWidth = measureChildWidth(child, style, width - paddingLeft - paddingRight, horizontalMargins)
@@ -1101,7 +1278,7 @@ internal class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
1101
1278
  val cellWidth = max(0f, (availableWidth.toFloat() - max(0, gridColumns - 1) * gap) / max(1, gridColumns).toFloat())
1102
1279
  val rows = mutableListOf<MutableList<Entry>>()
1103
1280
  val absolute = mutableListOf<Entry>()
1104
- for (index in 0 until childCount) {
1281
+ for (index in orderedChildIndices()) {
1105
1282
  val child = getChildAt(index)
1106
1283
  if (child.visibility == GONE) continue
1107
1284
  val style = styles[child] ?: CraftNativeLayoutStyle()
@@ -1109,13 +1286,19 @@ internal class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
1109
1286
  val horizontalMargins = (lp?.leftMargin ?: 0) + (lp?.rightMargin ?: 0)
1110
1287
  val verticalMargins = (lp?.topMargin ?: 0) + (lp?.bottomMargin ?: 0)
1111
1288
  if (child is CraftNativeModalView) {
1112
- val modalWidth = max(0, width - paddingLeft - paddingRight)
1113
- val modalHeight = max(0, height - paddingTop - paddingBottom)
1114
- child.measure(
1115
- View.MeasureSpec.makeMeasureSpec(modalWidth, View.MeasureSpec.EXACTLY),
1116
- View.MeasureSpec.makeMeasureSpec(modalHeight, View.MeasureSpec.EXACTLY),
1117
- )
1118
- absolute += Entry(child, style, modalWidth, modalHeight)
1289
+ if (child.blocksBackground) {
1290
+ val modalWidth = max(0, width - paddingLeft - paddingRight)
1291
+ val modalHeight = max(0, height - paddingTop - paddingBottom)
1292
+ child.measure(
1293
+ View.MeasureSpec.makeMeasureSpec(modalWidth, View.MeasureSpec.EXACTLY),
1294
+ View.MeasureSpec.makeMeasureSpec(modalHeight, View.MeasureSpec.EXACTLY),
1295
+ )
1296
+ absolute += Entry(child, style, modalWidth, modalHeight)
1297
+ } else {
1298
+ val childWidth = measureChildWidth(child, style, width - paddingLeft - paddingRight, horizontalMargins)
1299
+ val childHeight = measureChildHeight(child, style, height - paddingTop - paddingBottom, verticalMargins)
1300
+ absolute += Entry(child, style, childWidth, childHeight)
1301
+ }
1119
1302
  continue
1120
1303
  }
1121
1304
  val childWidth = measureChildWidth(child, style, cellWidth.roundToInt(), horizontalMargins)
@@ -1149,7 +1332,7 @@ internal class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
1149
1332
  val columnWidths = IntArray(columns)
1150
1333
  val rowHeights = mutableListOf<Int>()
1151
1334
  var flowIndex = 0
1152
- for (index in 0 until childCount) {
1335
+ for (index in orderedChildIndices()) {
1153
1336
  val child = getChildAt(index)
1154
1337
  if (child.visibility == GONE) continue
1155
1338
  val style = styles[child] ?: CraftNativeLayoutStyle()
@@ -1796,7 +1979,7 @@ internal class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
1796
1979
  if (absolute.isEmpty() && lines.isNotEmpty() && (lines.size == 1 || wrap)) {
1797
1980
  val sharedEntries = if (wrap) lines.flatMap { it.entries } else lines[0].entries
1798
1981
  val sharedTree = sharedTreeFrames(sharedEntries, width, height)
1799
- if (sharedTree != null && (sharedTree.nodes.size > sharedEntries.size + 1 || grid)) {
1982
+ if (sharedTree != null && (sharedTree.nodes.size > sharedEntries.size + 1 || grid || reverse || wrapReverse || gridAutoRows != null || gridRows != null)) {
1800
1983
  for (index in 1 until sharedTree.nodes.size) {
1801
1984
  val view = sharedTree.nodes[index].view ?: continue
1802
1985
  val offset = index * 4
@@ -2560,6 +2743,8 @@ private class CraftNativeModalView(context: Context) : FrameLayout(context) {
2560
2743
  private var animationGeneration = 0
2561
2744
  var focusToRestore: View? = null
2562
2745
  var onRequestClose: ((String) -> Unit)? = null
2746
+ var viewportStyle = JSONObject()
2747
+ var declarationPath = ""
2563
2748
 
2564
2749
  var transparent: Boolean = false
2565
2750
  set(value) {
@@ -2567,6 +2752,18 @@ private class CraftNativeModalView(context: Context) : FrameLayout(context) {
2567
2752
  blocker.setBackgroundColor(if (value) Color.TRANSPARENT else Color.argb(82, 0, 0, 0))
2568
2753
  }
2569
2754
 
2755
+ /** Existing Modal nodes block by default; non-modal popups let outside taps through. */
2756
+ var blocksBackground: Boolean = true
2757
+ set(value) {
2758
+ field = value
2759
+ isClickable = value
2760
+ blocker.isClickable = value
2761
+ blocker.isEnabled = value
2762
+ }
2763
+
2764
+ /** A non-modal popup may opt into consuming an outside tap for dismissal. */
2765
+ var dismissOnOutsideTap: Boolean = false
2766
+
2570
2767
  init {
2571
2768
  isClickable = true
2572
2769
  blocker.isClickable = true
@@ -2580,6 +2777,7 @@ private class CraftNativeModalView(context: Context) : FrameLayout(context) {
2580
2777
  }
2581
2778
 
2582
2779
  fun requestClose(reason: String = "backdrop") {
2780
+ if (!blocksBackground && !dismissOnOutsideTap) return
2583
2781
  val handler = onRequestClose ?: return
2584
2782
  isClickable = false
2585
2783
  blocker.isEnabled = false
@@ -2588,8 +2786,8 @@ private class CraftNativeModalView(context: Context) : FrameLayout(context) {
2588
2786
 
2589
2787
  fun setVisible(visible: Boolean, opacity: Float, animationType: String?) {
2590
2788
  if (visible) {
2591
- isClickable = true
2592
- blocker.isEnabled = true
2789
+ isClickable = blocksBackground
2790
+ blocker.isEnabled = blocksBackground
2593
2791
  }
2594
2792
  if (renderedVisible == visible && visibility == if (visible) View.VISIBLE else View.GONE) {
2595
2793
  if (visible && alpha != opacity) alpha = opacity
@@ -2643,9 +2841,33 @@ private class CraftNativeModalView(context: Context) : FrameLayout(context) {
2643
2841
  translationY = 0f
2644
2842
  alpha = 0f
2645
2843
  isClickable = true
2844
+ blocksBackground = true
2845
+ dismissOnOutsideTap = false
2646
2846
  blocker.isEnabled = true
2647
2847
  onRequestClose = null
2648
2848
  }
2849
+
2850
+ override fun onTouchEvent(event: MotionEvent): Boolean {
2851
+ if (!blocksBackground) {
2852
+ if (dismissOnOutsideTap && event.action == MotionEvent.ACTION_UP) {
2853
+ requestClose(reason = "backdrop")
2854
+ return true
2855
+ }
2856
+ // Do not let an outside popup tap fall through to a blocking modal
2857
+ // below it; a normal non-modal popup remains pass-through.
2858
+ return hasBlockingModalBelow()
2859
+ }
2860
+ return super.onTouchEvent(event)
2861
+ }
2862
+
2863
+ fun hasBlockingModalBelow(): Boolean {
2864
+ val group = parent as? ViewGroup ?: return false
2865
+ val index = group.indexOfChild(this)
2866
+ return index > 0 && (0 until index).any { siblingIndex ->
2867
+ val sibling = group.getChildAt(siblingIndex)
2868
+ sibling is CraftNativeModalView && sibling.blocksBackground && sibling.visibility == View.VISIBLE && sibling.alpha > 0.01f
2869
+ }
2870
+ }
2649
2871
  }
2650
2872
 
2651
2873
  private data class LayoutBounds(
@@ -2902,7 +3124,8 @@ internal class CraftNativeInputEcho {
2902
3124
  * A native screen's `<Video>`: the platform's VideoView.
2903
3125
  *
2904
3126
  * It plays without taking audio focus, so the person's music carries on
2905
- * under it, as on iOS. `onLoad`, `onPlay`, `onPause`, `onEnd` and `onError`
3127
+ * under it, as on iOS. `onLoadStart`, `onLoad`, `onLoadEnd`, `onPlay`,
3128
+ * `onPause`, `onEnd` and `onError`
2906
3129
  * reach the screen in the web's names, from the controls as well as from
2907
3130
  * props. With `pictureInPicture` the activity takes it into a floating
2908
3131
  * window when the person leaves the app while it plays (MainActivity
@@ -2918,6 +3141,8 @@ private class CraftNativeVideoView(context: Context) : VideoView(context) {
2918
3141
  private var ended = false
2919
3142
  private var muted = false
2920
3143
  private var resizeMode = "contain"
3144
+ private var requestedTimeSeconds: Double? = null
3145
+ private var pendingSeekSeconds: Double? = null
2921
3146
  private var preparedPlayer: MediaPlayer? = null
2922
3147
  /** The screen's handlers by event name, and how an event reaches them. */
2923
3148
  var handlers: JSONObject = JSONObject()
@@ -2946,12 +3171,25 @@ private class CraftNativeVideoView(context: Context) : VideoView(context) {
2946
3171
  setAudioFocusRequest(AudioManager.AUDIOFOCUS_NONE)
2947
3172
  }
2948
3173
 
2949
- fun configure(source: Any?, autoplay: Boolean, paused: Boolean, loop: Boolean, muted: Boolean, controls: Boolean, resizeMode: String?, pictureInPicture: Boolean = false) {
3174
+ private fun applyPendingSeek() {
3175
+ val target = pendingSeekSeconds ?: return
3176
+ if (preparedPlayer == null) return
3177
+ val current = currentPosition.coerceAtLeast(0) / 1000.0
3178
+ pendingSeekSeconds = null
3179
+ if (kotlin.math.abs(current - target) > 0.25) seekTo((target * 1000.0).roundToInt().coerceAtLeast(0))
3180
+ }
3181
+
3182
+ fun configure(source: Any?, autoplay: Boolean, paused: Boolean, loop: Boolean, muted: Boolean, currentTime: Any?, controls: Boolean, resizeMode: String?, pictureInPicture: Boolean = false) {
2950
3183
  val uri = when (source) {
2951
3184
  is String -> source
2952
3185
  is JSONObject -> craftNativeOptionalString(source.opt("uri")).orEmpty()
2953
3186
  else -> ""
2954
3187
  }
3188
+ val nextTime = craftNativeNumber(currentTime)?.takeIf { it.isFinite() && it >= 0.0 }
3189
+ if (nextTime != requestedTimeSeconds) {
3190
+ requestedTimeSeconds = nextTime
3191
+ pendingSeekSeconds = nextTime
3192
+ }
2955
3193
  shouldLoop = loop
2956
3194
  val nextShouldPlay = autoplay && !paused
2957
3195
  if (playIntent != nextShouldPlay) {
@@ -2971,6 +3209,7 @@ private class CraftNativeVideoView(context: Context) : VideoView(context) {
2971
3209
  setMediaController(if (controls) MediaController(context).also { it.setAnchorView(this) } else null)
2972
3210
  if (uri != sourceUri) {
2973
3211
  sourceUri = uri
3212
+ pendingSeekSeconds = requestedTimeSeconds
2974
3213
  userPaused = false
2975
3214
  ended = false
2976
3215
  stopPlayback()
@@ -2980,6 +3219,7 @@ private class CraftNativeVideoView(context: Context) : VideoView(context) {
2980
3219
  // A copy kept on the phone (craft.media.keep) plays in place of
2981
3220
  // the address: offline in a gym with no signal.
2982
3221
  val kept = CraftNativeMedia.local(context, uri)
3222
+ fire("onLoadStart")
2983
3223
  setVideoURI(if (kept != null) Uri.fromFile(kept) else Uri.parse(uri))
2984
3224
  setOnPreparedListener { mediaPlayer ->
2985
3225
  preparedPlayer = mediaPlayer
@@ -2987,7 +3227,9 @@ private class CraftNativeVideoView(context: Context) : VideoView(context) {
2987
3227
  mediaPlayer.isLooping = shouldLoop
2988
3228
  val volume = if (this.muted) 0f else 1f
2989
3229
  mediaPlayer.setVolume(volume, volume)
3230
+ applyPendingSeek()
2990
3231
  fire("onLoad", JSONObject().put("duration", duration / 1000.0))
3232
+ fire("onLoadEnd")
2991
3233
  if (shouldPlay) start()
2992
3234
  }
2993
3235
  setOnCompletionListener {
@@ -3005,6 +3247,7 @@ private class CraftNativeVideoView(context: Context) : VideoView(context) {
3005
3247
  }
3006
3248
  }
3007
3249
  if (uri.isBlank()) return
3250
+ applyPendingSeek()
3008
3251
  runCatching {
3009
3252
  if (shouldPlay && !isPlaying && !userPaused && !ended) start()
3010
3253
  else if (!shouldPlay && isPlaying) pause()
@@ -3074,6 +3317,8 @@ private class CraftNativeVideoView(context: Context) : VideoView(context) {
3074
3317
  stopPlayback()
3075
3318
  setPlaying(false)
3076
3319
  sourceUri = ""
3320
+ requestedTimeSeconds = null
3321
+ pendingSeekSeconds = null
3077
3322
  playIntent = null
3078
3323
  userPaused = false
3079
3324
  ended = false
@@ -4619,6 +4864,7 @@ class MainActivity : AppCompatActivity() {
4619
4864
  put("viewMeasure")
4620
4865
  put("viewScroll")
4621
4866
  put("viewReparent")
4867
+ put("viewNonModalPresentation")
4622
4868
  }
4623
4869
 
4624
4870
  private fun appVersion(): Pair<String, String> = runCatching {
@@ -4906,6 +5152,9 @@ class MainActivity : AppCompatActivity() {
4906
5152
  if (!updateParentLayoutStyle(updated, document.optJSONObject("style") ?: JSONObject())) return false
4907
5153
  }
4908
5154
  if (applied.isNotEmpty()) {
5155
+ // Updating a modal's content may reattach its nested popups to
5156
+ // their declaration parent. Restore the screen overlay contract.
5157
+ promoteModalOverlays(screen)
4909
5158
  scheduleLayoutEvents(screen)
4910
5159
  }
4911
5160
  return true
@@ -4995,6 +5244,7 @@ class MainActivity : AppCompatActivity() {
4995
5244
  screen.controls.putAll(next)
4996
5245
  screen.handlers.keys.retainAll(retainedViews(screen))
4997
5246
  screen.view.setChildStyle(tree, document.optJSONObject("style") ?: JSONObject(), resources.displayMetrics.density)
5247
+ promoteModalOverlays(screen)
4998
5248
  updateSafeAreaViews()
4999
5249
  // A full render can change a view's custom layout style without
5000
5250
  // changing the root bounds. Ensure native @layout observers still see
@@ -5003,6 +5253,45 @@ class MainActivity : AppCompatActivity() {
5003
5253
  if (visibleScreen === screen) applyStatusBar(screen)
5004
5254
  }
5005
5255
 
5256
+ /** Keep Modal nodes in the screen viewport even when declared in a clipped subtree. */
5257
+ private fun promoteModalOverlays(screen: Screen) {
5258
+ if (screen.modalVisibility.isEmpty()) return
5259
+ // Snapshot parent-before-child order before moving anything. The
5260
+ // controls map registers descendants first, which would place a
5261
+ // parent modal above its own popup when brought forward in that order.
5262
+ val overlays = mutableListOf<CraftNativeModalView>()
5263
+ fun collect(view: View) {
5264
+ if (view is CraftNativeModalView) overlays.add(view)
5265
+ if (view is ViewGroup) for (index in 0 until view.childCount) collect(view.getChildAt(index))
5266
+ }
5267
+ collect(screen.view)
5268
+ // A previous targeted update may already have promoted siblings in a
5269
+ // different order. Use declaration paths, with numeric child indices,
5270
+ // so a nested popup stays above its parent after every update.
5271
+ overlays.sortWith(Comparator { first, second ->
5272
+ val firstPath = first.declarationPath.split('.')
5273
+ val secondPath = second.declarationPath.split('.')
5274
+ for (index in 0 until min(firstPath.size, secondPath.size)) {
5275
+ val left = firstPath[index]
5276
+ val right = secondPath[index]
5277
+ val leftIndex = left.toIntOrNull()
5278
+ val rightIndex = right.toIntOrNull()
5279
+ val order = if (leftIndex != null && rightIndex != null) leftIndex.compareTo(rightIndex) else left.compareTo(right)
5280
+ if (order != 0) return@Comparator order
5281
+ }
5282
+ firstPath.size.compareTo(secondPath.size)
5283
+ })
5284
+ overlays.forEach { modal ->
5285
+ if (modal.parent !== screen.view) {
5286
+ (modal.parent as? ViewGroup)?.removeView(modal)
5287
+ screen.view.addView(modal)
5288
+ // Removing a view clears its old parent's flex style.
5289
+ screen.view.setChildStyle(modal, modal.viewportStyle, resources.displayMetrics.density)
5290
+ }
5291
+ modal.bringToFront()
5292
+ }
5293
+ }
5294
+
5006
5295
  private fun applyStatusBar(screen: Screen) {
5007
5296
  val controller = WindowCompat.getInsetsController(window, window.decorView)
5008
5297
  val night = (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
@@ -5365,6 +5654,15 @@ class MainActivity : AppCompatActivity() {
5365
5654
  "Modal" -> (previous as? CraftNativeModalView ?: CraftNativeModalView(this)).apply {
5366
5655
  val focusBeforeContent = screen.view.findFocus()
5367
5656
  transparent = craftNativeBoolean(props.opt("transparent"))
5657
+ blocksBackground = !craftNativeBoolean(props.opt("nonModal"))
5658
+ dismissOnOutsideTap = craftNativeBoolean(props.opt("dismissOnOutsideTap"))
5659
+ // Modal nodes are promoted from their declaration parent to
5660
+ // the screen root. Keep the authored layout style with that
5661
+ // promoted child so non-modal absolute coordinates remain in
5662
+ // the screen viewport rather than the old clipped parent.
5663
+ viewportStyle = style
5664
+ declarationPath = path
5665
+ screen.view.setChildStyle(this, style, resources.displayMetrics.density)
5368
5666
  content.configure(style, resources.displayMetrics.density)
5369
5667
  reconcileChildren(screen, content, children, identity, next, style, previousControls)
5370
5668
  // Dynamic :visible bindings arrive as a display style mutation;
@@ -5598,6 +5896,7 @@ class MainActivity : AppCompatActivity() {
5598
5896
  paused = craftNativeBoolean(props.opt("paused")),
5599
5897
  loop = craftNativeBoolean(props.opt("loop")),
5600
5898
  muted = craftNativeBoolean(props.opt("muted")),
5899
+ currentTime = props.opt("currentTime"),
5601
5900
  controls = craftNativeBoolean(props.opt("controls")),
5602
5901
  resizeMode = craftNativeResizeMode(style, props),
5603
5902
  pictureInPicture = craftNativeBoolean(props.opt("pictureInPicture")),
@@ -6001,6 +6300,7 @@ class MainActivity : AppCompatActivity() {
6001
6300
  if (layout.getChildAt(index) is CraftNativeFlexSpacer) layout.removeViewAt(index)
6002
6301
  }
6003
6302
  val desired = mutableListOf<View>()
6303
+ val desiredOrder = mutableMapOf<View, Int>()
6004
6304
  for (index in 0 until children.length()) {
6005
6305
  val child = children.opt(index)
6006
6306
  val childView = when (child) {
@@ -6013,9 +6313,12 @@ class MainActivity : AppCompatActivity() {
6013
6313
  }
6014
6314
  if (childView != null) desired.add(childView)
6015
6315
  if (childView != null && child is JSONObject) {
6016
- layout.setChildStyle(childView, child.optJSONObject("style") ?: JSONObject(), resources.displayMetrics.density)
6316
+ val childStyle = child.optJSONObject("style") ?: JSONObject()
6317
+ layout.setChildStyle(childView, childStyle, resources.displayMetrics.density)
6318
+ desiredOrder[childView] = craftNativeFiniteNumber(childStyle, "order", 0.0).toInt()
6017
6319
  }
6018
6320
  }
6321
+ desired.sortWith(compareBy { desiredOrder[it] ?: 0 })
6019
6322
  if (craftNativeOptionalString(style.opt("flexDirection"))?.endsWith("-reverse") == true) desired.reverse()
6020
6323
  val desiredSet = desired.toSet()
6021
6324
  for (index in layout.childCount - 1 downTo 0) {
@@ -6754,6 +7057,7 @@ class MainActivity : AppCompatActivity() {
6754
7057
  linkedControl -> "link"
6755
7058
  type in setOf("TouchableOpacity", "TouchableHighlight", "Pressable") -> "button"
6756
7059
  type == "Icon" -> "image"
7060
+ type in setOf("FlatList", "SectionList") -> "list"
6757
7061
  else -> type.lowercase()
6758
7062
  }
6759
7063
  }
@@ -7467,6 +7771,12 @@ class MainActivity : AppCompatActivity() {
7467
7771
  // released/recycled rows so a later identity cannot inherit a closure
7468
7772
  // that still captures the previous row's screen state.
7469
7773
  released.forEach {
7774
+ // Modals are promoted out of their logical layout into the
7775
+ // screen overlay. Removing the logical node therefore does not
7776
+ // remove the native view through ordinary child reconciliation;
7777
+ // detach every released view so an open popup cannot survive as
7778
+ // an orphan after its owner unmounts.
7779
+ (it.parent as? ViewGroup)?.removeView(it)
7470
7780
  // AdapterView (including Spinner, our native Picker) rejects
7471
7781
  // setOnClickListener altogether. Its selection callback is
7472
7782
  // detached below; ordinary Views still need their click target
@@ -111,6 +111,11 @@ dependencies {
111
111
  // Android Health Connect (included only when enabled)
112
112
  {{HEALTH_CONNECT_DEPENDENCIES}}
113
113
 
114
+ // Android Health Services live exercise tracking (included only when enabled)
115
+ {{HEALTH_SERVICES_DEPENDENCIES}}
116
+
117
+ {{WALLET_DEPENDENCY}}
118
+
114
119
  // On-device computer vision APIs exposed by CraftBridge
115
120
  implementation("com.google.mlkit:barcode-scanning:17.3.0")
116
121
  implementation("com.google.mlkit:image-labeling:17.0.9")