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.
- package/README.md +22 -0
- package/dist/android/src/index.d.ts +4 -2
- package/dist/android/src/index.js +65 -4
- package/dist/android/templates/AndroidManifest.xml.template +1 -0
- package/dist/android/templates/CraftHealthServices.kt.template +132 -0
- package/dist/android/templates/CraftHealthServicesService.kt.template +217 -0
- package/dist/android/templates/CraftHealthServicesStub.kt.template +16 -0
- package/dist/android/templates/CraftNativeHealth.kt.template +115 -0
- package/dist/android/templates/CraftNativeHealthStub.kt.template +1 -0
- package/dist/android/templates/CraftNativeMutation.kt.template +2 -0
- package/dist/android/templates/CraftNativeServices.kt.template +31 -8
- package/dist/android/templates/CraftNativeShell.kt.template +5 -0
- package/dist/android/templates/MainActivityNative.kt.template +343 -33
- package/dist/android/templates/build.gradle.kts.app.template +5 -0
- package/dist/api/mobile.d.ts +9 -1
- package/dist/browser/lifecycle.d.ts +7 -0
- package/dist/browser.js +126 -24
- package/dist/cli.js +1 -1
- package/dist/components/index.d.ts +19 -10
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/templates/CraftApp.swift +55 -7
- package/dist/ios/templates/CraftHealth.swift +38 -0
- package/dist/ios/templates/CraftHybrid.swift +50 -7
- package/dist/ios/templates/CraftNativeActions.swift +109 -22
- package/dist/ios/templates/CraftNativeFlatList.swift +187 -18
- package/dist/ios/templates/CraftNativeMutation.swift +3 -1
- package/dist/ios/templates/CraftNativeScreen.swift +466 -24
- package/dist/macos/templates/CraftNativeMutation.swift +3 -1
- package/dist/mobile.js +1 -1
- 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
|
|
496
|
-
|
|
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.
|
|
501
|
-
val command =
|
|
502
|
-
fun number(offset: Int): Float =
|
|
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.
|
|
505
|
-
1 -> if (index + 2 < geometry.
|
|
506
|
-
2 -> if (index + 6 < geometry.
|
|
507
|
-
3 -> if (index + 4 < geometry.
|
|
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
|
|
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
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
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
|
|
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
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
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
|
|
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 =
|
|
2592
|
-
blocker.isEnabled =
|
|
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. `
|
|
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
|
|
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
|
-
|
|
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")
|