craft-native 0.0.163 → 0.0.164
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/dist/android/templates/MainActivityNative.kt.template +108 -91
- package/dist/api/mobile.d.ts +10 -2
- package/dist/cli.js +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/templates/CraftApp.swift +31 -0
- package/dist/ios/templates/CraftHealth.swift +48 -2
- package/dist/ios/templates/CraftHybrid.swift +49 -0
- package/dist/ios/templates/CraftNativeActions.swift +111 -1
- package/dist/ios/templates/CraftNativeFiles.swift +107 -0
- package/dist/ios/templates/CraftNativeScreen.swift +34 -3
- package/dist/mobile.js +6 -0
- package/package.json +1 -1
|
@@ -88,6 +88,7 @@ import java.util.UUID
|
|
|
88
88
|
import kotlin.math.max
|
|
89
89
|
import kotlin.math.min
|
|
90
90
|
import kotlin.math.PI
|
|
91
|
+
import kotlin.math.ceil
|
|
91
92
|
import kotlin.math.roundToInt
|
|
92
93
|
import kotlin.math.roundToLong
|
|
93
94
|
import kotlin.math.sin
|
|
@@ -202,13 +203,16 @@ private class CraftNativeSvgView(context: Context) : View(context) {
|
|
|
202
203
|
props.has(name) && !props.isNull(name) -> props.optString(name)
|
|
203
204
|
else -> null
|
|
204
205
|
}
|
|
206
|
+
// Keep the cascade explicit: computed style wins over a raw prop
|
|
207
|
+
// when both provide the same SVG paint value.
|
|
205
208
|
val weight = value("fontWeight")
|
|
209
|
+
val ownOpacity = value("opacity")?.toFloatOrNull() ?: 1f
|
|
206
210
|
return copy(
|
|
207
211
|
fill = value("fill") ?: fill,
|
|
208
212
|
stroke = value("stroke") ?: stroke,
|
|
209
213
|
strokeWidth = value("strokeWidth")?.toFloatOrNull() ?: strokeWidth,
|
|
210
214
|
color = value("color") ?: color,
|
|
211
|
-
opacity =
|
|
215
|
+
opacity = ownOpacity * opacity,
|
|
212
216
|
typeface = if (weight in setOf("bold", "700")) Typeface.DEFAULT_BOLD else typeface,
|
|
213
217
|
)
|
|
214
218
|
}
|
|
@@ -393,8 +397,8 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
|
|
|
393
397
|
private var alignItems = "stretch"
|
|
394
398
|
private var alignContent = "flex-start"
|
|
395
399
|
private var justifyContent = "flex-start"
|
|
396
|
-
private var rowGap =
|
|
397
|
-
private var columnGap =
|
|
400
|
+
private var rowGap = 0f
|
|
401
|
+
private var columnGap = 0f
|
|
398
402
|
private var grid = false
|
|
399
403
|
private var gridColumns = 1
|
|
400
404
|
private var gridAutoRows: CraftNativeLength? = null
|
|
@@ -407,9 +411,9 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
|
|
|
407
411
|
alignItems = style.optString("alignItems", "stretch")
|
|
408
412
|
alignContent = style.optString("alignContent", "flex-start")
|
|
409
413
|
justifyContent = style.optString("justifyContent", "flex-start")
|
|
410
|
-
val gap =
|
|
411
|
-
rowGap =
|
|
412
|
-
columnGap =
|
|
414
|
+
val gap = style.optDouble("gap", 0.0) * density
|
|
415
|
+
rowGap = style.optDouble("rowGap", gap / density.toDouble()).toFloat() * density
|
|
416
|
+
columnGap = style.optDouble("columnGap", gap / density.toDouble()).toFloat() * density
|
|
413
417
|
grid = style.optString("display") == "grid"
|
|
414
418
|
gridColumns = craftNativeGridColumnCount(
|
|
415
419
|
if (style.has("gridTemplateColumns")) style.opt("gridTemplateColumns") else style.opt("gridColumns")
|
|
@@ -438,8 +442,8 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
|
|
|
438
442
|
}
|
|
439
443
|
|
|
440
444
|
private data class Entry(val view: View, val style: CraftNativeLayoutStyle, val width: Int, val height: Int)
|
|
441
|
-
private data class Line(val entries: MutableList<Entry> = mutableListOf(), var main:
|
|
442
|
-
private data class GridLayout(val rows: List<List<Entry>>, val absolute: List<Entry>, val cellWidth:
|
|
445
|
+
private data class Line(val entries: MutableList<Entry> = mutableListOf(), var main: Float = 0f, var cross: Float = 0f)
|
|
446
|
+
private data class GridLayout(val rows: List<List<Entry>>, val absolute: List<Entry>, val cellWidth: Float, val rowHeights: List<Float>)
|
|
443
447
|
|
|
444
448
|
private fun entries(width: Int, height: Int): Pair<List<Line>, List<Entry>> {
|
|
445
449
|
val availableMain = if (orientation == HORIZONTAL) max(0, width - paddingLeft - paddingRight)
|
|
@@ -487,8 +491,8 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
|
|
|
487
491
|
}
|
|
488
492
|
var line = Line()
|
|
489
493
|
pending.forEachIndexed { index, entry ->
|
|
490
|
-
val childMain = if (orientation == HORIZONTAL) entry.width +
|
|
491
|
-
else entry.height +
|
|
494
|
+
val childMain = if (orientation == HORIZONTAL) entry.width.toFloat() + margins[index * 2] + margins[index * 2 + 1]
|
|
495
|
+
else entry.height.toFloat() + margins[index * 2] + margins[index * 2 + 1]
|
|
492
496
|
val lp = entry.view.layoutParams as? MarginLayoutParams
|
|
493
497
|
val crossMargins = if (orientation == HORIZONTAL) (lp?.topMargin ?: 0) + (lp?.bottomMargin ?: 0)
|
|
494
498
|
else (lp?.leftMargin ?: 0) + (lp?.rightMargin ?: 0)
|
|
@@ -502,17 +506,22 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
|
|
|
502
506
|
}
|
|
503
507
|
line.entries += entry
|
|
504
508
|
line.main = if (line.entries.size == 1) childMain else line.main + gap + childMain
|
|
505
|
-
line.cross = max(line.cross, childCross)
|
|
509
|
+
line.cross = max(line.cross, childCross.toFloat())
|
|
506
510
|
}
|
|
507
511
|
if (line.entries.isNotEmpty()) lines += line
|
|
508
512
|
}
|
|
513
|
+
// Reconciliation reverses the child order so the main axis runs in
|
|
514
|
+
// the requested direction. With wrapping, that also reverses the
|
|
515
|
+
// line order; flexbox keeps wrap lines in cross-axis order, so restore
|
|
516
|
+
// the groups while retaining the reversed entries inside each line.
|
|
517
|
+
if (reverse && wrap && lines.size > 1) lines.reverse()
|
|
509
518
|
return lines to absolute
|
|
510
519
|
}
|
|
511
520
|
|
|
512
521
|
private fun gridLayout(width: Int, height: Int): GridLayout {
|
|
513
522
|
val availableWidth = max(0, width - paddingLeft - paddingRight)
|
|
514
523
|
val gap = columnGap
|
|
515
|
-
val cellWidth = max(
|
|
524
|
+
val cellWidth = max(0f, (availableWidth.toFloat() - max(0, gridColumns - 1) * gap) / max(1, gridColumns).toFloat())
|
|
516
525
|
val rows = mutableListOf<MutableList<Entry>>()
|
|
517
526
|
val absolute = mutableListOf<Entry>()
|
|
518
527
|
for (index in 0 until childCount) {
|
|
@@ -532,7 +541,7 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
|
|
|
532
541
|
absolute += Entry(child, style, modalWidth, modalHeight)
|
|
533
542
|
continue
|
|
534
543
|
}
|
|
535
|
-
val childWidth = measureChildWidth(child, style, cellWidth, horizontalMargins)
|
|
544
|
+
val childWidth = measureChildWidth(child, style, cellWidth.roundToInt(), horizontalMargins)
|
|
536
545
|
val childHeight = measureChildHeight(child, style, height - paddingTop - paddingBottom, verticalMargins)
|
|
537
546
|
val entry = Entry(child, style, childWidth, childHeight)
|
|
538
547
|
if (style.position == "absolute") absolute += entry
|
|
@@ -542,10 +551,10 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
|
|
|
542
551
|
}
|
|
543
552
|
}
|
|
544
553
|
val rowHeights = rows.map { row ->
|
|
545
|
-
max(gridAutoRows?.resolve(height - paddingTop - paddingBottom) ?: 0, row.maxOfOrNull { entry ->
|
|
554
|
+
max((gridAutoRows?.resolve(height - paddingTop - paddingBottom) ?: 0).toFloat(), row.maxOfOrNull { entry ->
|
|
546
555
|
val lp = entry.view.layoutParams as? MarginLayoutParams
|
|
547
|
-
entry.height + (lp?.topMargin ?: 0) + (lp?.bottomMargin ?: 0)
|
|
548
|
-
} ?:
|
|
556
|
+
(entry.height + (lp?.topMargin ?: 0) + (lp?.bottomMargin ?: 0)).toFloat()
|
|
557
|
+
} ?: 0f)
|
|
549
558
|
}
|
|
550
559
|
return GridLayout(rows, absolute, cellWidth, rowHeights)
|
|
551
560
|
}
|
|
@@ -573,9 +582,9 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
|
|
|
573
582
|
flowIndex += 1
|
|
574
583
|
}
|
|
575
584
|
val cellWidth = columnWidths.maxOrNull() ?: 0
|
|
576
|
-
val width = paddingLeft + paddingRight + cellWidth * columns + max(0, columns - 1) * columnGap
|
|
585
|
+
val width = paddingLeft + paddingRight + cellWidth * columns + (max(0, columns - 1) * columnGap).roundToInt()
|
|
577
586
|
val rowGapTotal = max(0, rowHeights.size - 1) * rowGap
|
|
578
|
-
val measuredHeight = paddingTop + paddingBottom + rowHeights.sum() + rowGapTotal
|
|
587
|
+
val measuredHeight = paddingTop + paddingBottom + rowHeights.sum() + rowGapTotal.roundToInt()
|
|
579
588
|
return width to measuredHeight
|
|
580
589
|
}
|
|
581
590
|
|
|
@@ -658,15 +667,15 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
|
|
|
658
667
|
}
|
|
659
668
|
|
|
660
669
|
/** Keep the old-runtime shim on the same bounded-flex water-fill rule as the Zig bridge. */
|
|
661
|
-
private fun fallbackFlexWidths(line: Line, available: Int, gap:
|
|
670
|
+
private fun fallbackFlexWidths(line: Line, available: Int, gap: Float): IntArray {
|
|
662
671
|
val horizontal = orientation == HORIZONTAL
|
|
663
672
|
val hypothetical = line.entries.sumOf { entry ->
|
|
664
673
|
val lp = entry.view.layoutParams as? MarginLayoutParams
|
|
665
674
|
val margins = if (horizontal) (lp?.leftMargin ?: 0) + (lp?.rightMargin ?: 0)
|
|
666
675
|
else (lp?.topMargin ?: 0) + (lp?.bottomMargin ?: 0)
|
|
667
|
-
(if (horizontal) entry.width else entry.height) + margins
|
|
668
|
-
} + max(0, line.entries.size - 1) * gap
|
|
669
|
-
val free = available - hypothetical
|
|
676
|
+
((if (horizontal) entry.width else entry.height) + margins).toDouble()
|
|
677
|
+
} + max(0, line.entries.size - 1) * gap.toDouble()
|
|
678
|
+
val free = available.toDouble() - hypothetical
|
|
670
679
|
val growTotal = line.entries.sumOf { it.style.flexGrow }
|
|
671
680
|
val shrinkTotal = line.entries.sumOf {
|
|
672
681
|
(it.style.flexShrink ?: defaultFlexShrink(it.view)) * (if (horizontal) it.width else it.height)
|
|
@@ -695,7 +704,7 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
|
|
|
695
704
|
}
|
|
696
705
|
}
|
|
697
706
|
if (factor <= 0) return@repeat
|
|
698
|
-
val remaining = available - fixed - activeBasis
|
|
707
|
+
val remaining = available.toDouble() - fixed - activeBasis
|
|
699
708
|
var froze = false
|
|
700
709
|
for ((index, entry) in line.entries.withIndex()) {
|
|
701
710
|
if (frozen[index]) continue
|
|
@@ -733,16 +742,16 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
|
|
|
733
742
|
return
|
|
734
743
|
}
|
|
735
744
|
val layout = gridLayout(width, height)
|
|
736
|
-
val desiredWidth = paddingLeft + paddingRight + layout.cellWidth * gridColumns + max(0, gridColumns - 1) * columnGap
|
|
737
|
-
val desiredHeight = paddingTop + paddingBottom + layout.rowHeights.sum() + max(0, layout.rowHeights.size - 1) * rowGap
|
|
745
|
+
val desiredWidth = paddingLeft + paddingRight + (layout.cellWidth * gridColumns + max(0, gridColumns - 1) * columnGap).roundToInt()
|
|
746
|
+
val desiredHeight = paddingTop + paddingBottom + layout.rowHeights.sum().roundToInt() + (max(0, layout.rowHeights.size - 1) * rowGap).roundToInt()
|
|
738
747
|
setMeasuredDimension(resolveSize(desiredWidth, widthMeasureSpec), resolveSize(desiredHeight, heightMeasureSpec))
|
|
739
748
|
return
|
|
740
749
|
}
|
|
741
750
|
val (lines, absolute) = entries(width, height)
|
|
742
|
-
val main = lines.maxOfOrNull { it.main } ?:
|
|
743
|
-
val cross = lines.sumOf { it.cross } + max(0, lines.size - 1) * (if (orientation == HORIZONTAL) rowGap else columnGap)
|
|
744
|
-
val desiredWidth = if (orientation == HORIZONTAL) main else cross
|
|
745
|
-
val desiredHeight = if (orientation == HORIZONTAL) cross else main
|
|
751
|
+
val main = lines.maxOfOrNull { it.main } ?: 0f
|
|
752
|
+
val cross = lines.sumOf { it.cross.toDouble() } + max(0, lines.size - 1) * (if (orientation == HORIZONTAL) rowGap else columnGap).toDouble()
|
|
753
|
+
val desiredWidth = if (orientation == HORIZONTAL) main.roundToInt() else cross.roundToInt()
|
|
754
|
+
val desiredHeight = if (orientation == HORIZONTAL) cross.roundToInt() else main.roundToInt()
|
|
746
755
|
val absWidth = absolute.maxOfOrNull { it.width } ?: 0
|
|
747
756
|
val absHeight = absolute.maxOfOrNull { it.height } ?: 0
|
|
748
757
|
setMeasuredDimension(
|
|
@@ -760,12 +769,12 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
|
|
|
760
769
|
// Older runtimes return null for the optional symbol; the local
|
|
761
770
|
// layout below remains the compatibility path in that case.
|
|
762
771
|
val sharedFrames = CraftNative.grid(
|
|
763
|
-
contentWidth.toFloat(), contentHeight.toFloat(), gridColumns, columnGap
|
|
772
|
+
contentWidth.toFloat(), contentHeight.toFloat(), gridColumns, columnGap, rowGap,
|
|
764
773
|
flow.map { it.width.toFloat() }.toFloatArray(), flow.map { it.height.toFloat() }.toFloatArray(),
|
|
765
|
-
flow.map { it.style.width?.resolve(layout.cellWidth)?.toFloat() ?: Float.NaN }.toFloatArray(),
|
|
774
|
+
flow.map { it.style.width?.resolve(layout.cellWidth.roundToInt())?.toFloat() ?: Float.NaN }.toFloatArray(),
|
|
766
775
|
flow.map { it.style.height?.resolve(null)?.toFloat() ?: Float.NaN }.toFloatArray(),
|
|
767
|
-
flow.map { it.style.minWidth?.resolve(layout.cellWidth)?.toFloat() ?: Float.NaN }.toFloatArray(),
|
|
768
|
-
flow.map { it.style.maxWidth?.resolve(layout.cellWidth)?.toFloat() ?: Float.NaN }.toFloatArray(),
|
|
776
|
+
flow.map { it.style.minWidth?.resolve(layout.cellWidth.roundToInt())?.toFloat() ?: Float.NaN }.toFloatArray(),
|
|
777
|
+
flow.map { it.style.maxWidth?.resolve(layout.cellWidth.roundToInt())?.toFloat() ?: Float.NaN }.toFloatArray(),
|
|
769
778
|
flow.map { it.style.minHeight?.resolve(null)?.toFloat() ?: Float.NaN }.toFloatArray(),
|
|
770
779
|
flow.map { it.style.maxHeight?.resolve(null)?.toFloat() ?: Float.NaN }.toFloatArray(),
|
|
771
780
|
flow.flatMap {
|
|
@@ -790,8 +799,8 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
|
|
|
790
799
|
}
|
|
791
800
|
} else {
|
|
792
801
|
val totalRows = layout.rowHeights.sum()
|
|
793
|
-
val rowGaps = max(0, layout.rowHeights.size - 1) * rowGap
|
|
794
|
-
val freeRows = max(
|
|
802
|
+
val rowGaps = max(0, layout.rowHeights.size - 1) * rowGap.toFloat()
|
|
803
|
+
val freeRows = max(0f, contentHeight.toFloat() - totalRows - rowGaps)
|
|
795
804
|
val rowCount = max(1, layout.rowHeights.size)
|
|
796
805
|
var rowLeading = 0f
|
|
797
806
|
var rowBetween = rowGap.toFloat()
|
|
@@ -820,15 +829,15 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
|
|
|
820
829
|
val marginRight = lp?.rightMargin ?: 0
|
|
821
830
|
val marginTop = lp?.topMargin ?: 0
|
|
822
831
|
val marginBottom = lp?.bottomMargin ?: 0
|
|
823
|
-
val availableWidth = max(
|
|
832
|
+
val availableWidth = max(0f, layout.cellWidth - marginLeft - marginRight)
|
|
824
833
|
val availableHeight = max(0, (rowHeight - marginTop - marginBottom).roundToInt())
|
|
825
|
-
val childWidth = if (align == "stretch" && entry.style.width == null) availableWidth else min(availableWidth, entry.width)
|
|
834
|
+
val childWidth = if (align == "stretch" && entry.style.width == null) availableWidth.roundToInt() else min(availableWidth.roundToInt(), entry.width)
|
|
826
835
|
val childHeight = if (align == "stretch" && entry.style.height == null) availableHeight else min(availableHeight, entry.height)
|
|
827
|
-
val x = paddingLeft + column * (layout.cellWidth + columnGap) + marginLeft + when (align) {
|
|
828
|
-
"center" -> (availableWidth - childWidth) /
|
|
836
|
+
val x = (paddingLeft + column * (layout.cellWidth + columnGap) + marginLeft + when (align) {
|
|
837
|
+
"center" -> (availableWidth - childWidth) / 2f
|
|
829
838
|
"flex-end" -> availableWidth - childWidth
|
|
830
|
-
else ->
|
|
831
|
-
}
|
|
839
|
+
else -> 0f
|
|
840
|
+
}).roundToInt()
|
|
832
841
|
val y = (rowTop + marginTop + when (align) {
|
|
833
842
|
"center" -> (availableHeight - childHeight) / 2
|
|
834
843
|
"flex-end" -> availableHeight - childHeight
|
|
@@ -857,42 +866,42 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
|
|
|
857
866
|
val crossGap = if (orientation == HORIZONTAL) rowGap else columnGap
|
|
858
867
|
val availableMain = if (orientation == HORIZONTAL) contentWidth else contentHeight
|
|
859
868
|
val availableCross = if (orientation == HORIZONTAL) contentHeight else contentWidth
|
|
860
|
-
val totalCross = lines.sumOf { it.cross } + max(0, lines.size - 1) * crossGap
|
|
861
|
-
val crossFree = max(
|
|
862
|
-
var crossLeading =
|
|
869
|
+
val totalCross = lines.sumOf { it.cross.toDouble() }.toFloat() + max(0, lines.size - 1) * crossGap
|
|
870
|
+
val crossFree = max(0f, availableCross.toFloat() - totalCross)
|
|
871
|
+
var crossLeading = 0f
|
|
863
872
|
var crossBetween = crossGap
|
|
864
873
|
val sharedCross = if (wrap && lines.size > 1) {
|
|
865
874
|
CraftNative.flexLineDistribution(
|
|
866
|
-
availableCross.toFloat(), crossGap
|
|
875
|
+
availableCross.toFloat(), crossGap, alignContentCode(), lines.map { it.cross }.toFloatArray()
|
|
867
876
|
)
|
|
868
877
|
} else null
|
|
869
|
-
sharedCross?.second?.forEachIndexed { index, size -> lines[index].cross = size
|
|
878
|
+
sharedCross?.second?.forEachIndexed { index, size -> lines[index].cross = size }
|
|
870
879
|
if (wrap && lines.size > 1) {
|
|
871
880
|
if (sharedCross == null) when (alignContent) {
|
|
872
|
-
"center" -> crossLeading = crossFree /
|
|
881
|
+
"center" -> crossLeading = crossFree / 2f
|
|
873
882
|
"flex-end" -> crossLeading = crossFree
|
|
874
|
-
"space-between" -> crossBetween = crossFree / (lines.size - 1) + crossGap
|
|
883
|
+
"space-between" -> crossBetween = crossFree / (lines.size - 1).toFloat() + crossGap
|
|
875
884
|
"space-around" -> {
|
|
876
|
-
crossBetween = crossFree / lines.size + crossGap
|
|
877
|
-
crossLeading = crossFree / (lines.size * 2)
|
|
885
|
+
crossBetween = crossFree / lines.size.toFloat() + crossGap
|
|
886
|
+
crossLeading = crossFree / (lines.size * 2).toFloat()
|
|
878
887
|
}
|
|
879
888
|
"space-evenly" -> {
|
|
880
|
-
crossBetween = crossFree / (lines.size + 1) + crossGap
|
|
881
|
-
crossLeading = crossFree / (lines.size + 1)
|
|
882
|
-
}
|
|
883
|
-
"stretch" -> {
|
|
884
|
-
val extra = crossFree / lines.size
|
|
885
|
-
lines.forEach { it.cross += extra }
|
|
889
|
+
crossBetween = crossFree / (lines.size + 1).toFloat() + crossGap
|
|
890
|
+
crossLeading = crossFree / (lines.size + 1).toFloat()
|
|
886
891
|
}
|
|
887
892
|
}
|
|
888
893
|
}
|
|
889
|
-
|
|
894
|
+
val stretchExtra = if (sharedCross == null && wrap && lines.size > 1 && alignContent == "stretch")
|
|
895
|
+
crossFree / lines.size.toFloat()
|
|
896
|
+
else 0f
|
|
897
|
+
var crossOffset = (if (orientation == HORIZONTAL) paddingTop else paddingLeft).toFloat() + crossLeading
|
|
890
898
|
for ((lineIndex, line) in lines.withIndex()) {
|
|
891
|
-
val
|
|
899
|
+
val lineCross = line.cross.toFloat() + stretchExtra
|
|
900
|
+
val lineOffset = sharedCross?.first?.get(lineIndex)?.let { (if (orientation == HORIZONTAL) paddingTop else paddingLeft).toFloat() + it } ?: crossOffset
|
|
892
901
|
val fallbackWidths = fallbackFlexWidths(line, availableMain, gap)
|
|
893
902
|
val sharedPrimary = CraftNative.flexRow(
|
|
894
903
|
availableMain.toFloat(),
|
|
895
|
-
gap
|
|
904
|
+
gap,
|
|
896
905
|
justifyCode(),
|
|
897
906
|
line.entries.map { entry -> if (orientation == HORIZONTAL) entry.width.toFloat() else entry.height.toFloat() }.toFloatArray(),
|
|
898
907
|
line.entries.map { it.style.flexGrow.toFloat() }.toFloatArray(),
|
|
@@ -917,8 +926,8 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
|
|
|
917
926
|
val lp = line.entries[index].view.layoutParams as? MarginLayoutParams
|
|
918
927
|
val margins = if (orientation == HORIZONTAL) (lp?.leftMargin ?: 0) + (lp?.rightMargin ?: 0)
|
|
919
928
|
else (lp?.topMargin ?: 0) + (lp?.bottomMargin ?: 0)
|
|
920
|
-
sharedPrimary[index].
|
|
921
|
-
} + max(0, line.entries.size - 1) * gap
|
|
929
|
+
sharedPrimary[index].toDouble() + margins
|
|
930
|
+
} + max(0, line.entries.size - 1) * gap.toDouble()
|
|
922
931
|
} else {
|
|
923
932
|
line.entries.indices.sumOf { index ->
|
|
924
933
|
val constrained = fallbackWidths[index]
|
|
@@ -926,9 +935,9 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
|
|
|
926
935
|
val margins = if (orientation == HORIZONTAL) (lp?.leftMargin ?: 0) + (lp?.rightMargin ?: 0)
|
|
927
936
|
else (lp?.topMargin ?: 0) + (lp?.bottomMargin ?: 0)
|
|
928
937
|
constrained + margins
|
|
929
|
-
} + max(0, line.entries.size - 1) * gap
|
|
938
|
+
}.toDouble() + max(0, line.entries.size - 1) * gap.toDouble()
|
|
930
939
|
}
|
|
931
|
-
val free = max(
|
|
940
|
+
val free = max(0f, availableMain.toFloat() - distributedMain.toFloat())
|
|
932
941
|
val (leading, between) = distribution(free, line.entries.size)
|
|
933
942
|
var mainOffset = (if (orientation == HORIZONTAL) paddingLeft else paddingTop).toFloat() + leading
|
|
934
943
|
for ((index, entry) in line.entries.withIndex()) {
|
|
@@ -936,38 +945,40 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
|
|
|
936
945
|
val marginStart = if (orientation == HORIZONTAL) lp?.leftMargin ?: 0 else lp?.topMargin ?: 0
|
|
937
946
|
val marginEnd = if (orientation == HORIZONTAL) lp?.rightMargin ?: 0 else lp?.bottomMargin ?: 0
|
|
938
947
|
val crossSize = if ((entry.style.alignSelf ?: alignItems) == "stretch" && (if (orientation == HORIZONTAL) entry.style.height else entry.style.width) == null)
|
|
939
|
-
max(0,
|
|
948
|
+
max(0, (lineCross - (if (orientation == HORIZONTAL) (lp?.topMargin ?: 0) + (lp?.bottomMargin ?: 0) else (lp?.leftMargin ?: 0) + (lp?.rightMargin ?: 0))).roundToInt())
|
|
940
949
|
else if (orientation == HORIZONTAL) entry.height else entry.width
|
|
941
950
|
val align = entry.style.alignSelf ?: alignItems
|
|
942
|
-
val crossLeading = if (orientation == HORIZONTAL) lp?.topMargin ?: 0 else lp?.leftMargin ?: 0
|
|
943
|
-
val crossTrailing = if (orientation == HORIZONTAL) lp?.bottomMargin ?: 0 else lp?.rightMargin ?: 0
|
|
951
|
+
val crossLeading = (if (orientation == HORIZONTAL) lp?.topMargin ?: 0 else lp?.leftMargin ?: 0).toFloat()
|
|
952
|
+
val crossTrailing = (if (orientation == HORIZONTAL) lp?.bottomMargin ?: 0 else lp?.rightMargin ?: 0).toFloat()
|
|
944
953
|
val alignment = when (align) {
|
|
945
954
|
"center" -> 1
|
|
946
955
|
"flex-end" -> 2
|
|
947
956
|
else -> 0
|
|
948
957
|
}
|
|
949
958
|
val crossPosition = CraftNative.crossAxisPosition(
|
|
950
|
-
|
|
951
|
-
)
|
|
952
|
-
1 -> crossLeading + (
|
|
953
|
-
2 ->
|
|
959
|
+
lineCross, crossSize.toFloat(), crossLeading, crossTrailing, alignment
|
|
960
|
+
) ?: when (alignment) {
|
|
961
|
+
1 -> crossLeading + (lineCross - crossLeading - crossTrailing - crossSize) / 2f
|
|
962
|
+
2 -> lineCross - crossTrailing - crossSize
|
|
954
963
|
else -> crossLeading
|
|
955
964
|
}
|
|
956
965
|
// Shared positions cover every non-reverse justification; the
|
|
957
966
|
// local main-axis rule remains the fallback for old runtimes
|
|
958
967
|
// that return widths only.
|
|
959
|
-
|
|
968
|
+
// The shared result stores widths in the first half and
|
|
969
|
+
// main-axis positions in the second half.
|
|
970
|
+
val sharedPosition = if (!reverse) sharedPositions?.get(line.entries.size + index) else null
|
|
960
971
|
val positionedMain = sharedPosition?.let { (if (orientation == HORIZONTAL) paddingLeft else paddingTop).toFloat() + it }
|
|
961
972
|
?: mainOffset + marginStart
|
|
962
|
-
val x = if (orientation == HORIZONTAL) positionedMain.roundToInt() else lineOffset + crossPosition
|
|
963
|
-
val y = if (orientation == HORIZONTAL) lineOffset + crossPosition else positionedMain.roundToInt()
|
|
973
|
+
val x = if (orientation == HORIZONTAL) positionedMain.roundToInt() else (lineOffset + crossPosition).roundToInt()
|
|
974
|
+
val y = if (orientation == HORIZONTAL) (lineOffset + crossPosition).roundToInt() else positionedMain.roundToInt()
|
|
964
975
|
val distributedPrimary = sharedPrimary?.get(index)?.roundToInt() ?: fallbackWidths[index]
|
|
965
976
|
val w = if (orientation == HORIZONTAL) distributedPrimary else crossSize
|
|
966
977
|
val h = if (orientation == HORIZONTAL) crossSize else distributedPrimary
|
|
967
978
|
entry.view.layout(x, y, x + w, y + h)
|
|
968
979
|
if (sharedPosition == null) mainOffset += distributedPrimary + marginStart + marginEnd + gap + between
|
|
969
980
|
}
|
|
970
|
-
if (sharedCross == null) crossOffset +=
|
|
981
|
+
if (sharedCross == null) crossOffset += lineCross + crossBetween
|
|
971
982
|
}
|
|
972
983
|
for (entry in absolute) {
|
|
973
984
|
if (entry.view is CraftNativeModalView && entry.view.visibility == View.VISIBLE) entry.view.bringToFront()
|
|
@@ -980,8 +991,8 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
|
|
|
980
991
|
onLayoutChanged?.invoke()
|
|
981
992
|
}
|
|
982
993
|
|
|
983
|
-
private fun distribution(free:
|
|
984
|
-
val available = free
|
|
994
|
+
private fun distribution(free: Float, count: Int): Pair<Float, Float> {
|
|
995
|
+
val available = free
|
|
985
996
|
return when (justifyContent) {
|
|
986
997
|
"center" -> available / 2f to 0f
|
|
987
998
|
"flex-end" -> available to 0f
|
|
@@ -2723,12 +2734,24 @@ class MainActivity : AppCompatActivity() {
|
|
|
2723
2734
|
val minimum = props.optDouble("minimumValue", 0.0)
|
|
2724
2735
|
val maximumCandidate = props.optDouble("maximumValue", 1.0)
|
|
2725
2736
|
val maximum = if (maximumCandidate > minimum) maximumCandidate else minimum + Double.MIN_VALUE
|
|
2726
|
-
max = 1000
|
|
2727
2737
|
setOnSeekBarChangeListener(null)
|
|
2728
2738
|
val step = props.optDouble("step", 0.0).takeIf { it > 0 }
|
|
2739
|
+
// A stepped slider needs one native position per authored
|
|
2740
|
+
// step; a fixed 1,000-position SeekBar silently made large
|
|
2741
|
+
// ranges unable to represent most valid values. Continuous
|
|
2742
|
+
// sliders retain the bounded scale to avoid an enormous max.
|
|
2743
|
+
val progressMax = step?.let {
|
|
2744
|
+
ceil((maximum - minimum) / it).toInt().coerceIn(1, 1_000_000)
|
|
2745
|
+
} ?: 1000
|
|
2746
|
+
max = progressMax
|
|
2729
2747
|
val requested = props.optDouble("value", minimum)
|
|
2730
|
-
val value = if (step != null) minimum + ((requested - minimum) / step).roundToInt() * step else requested
|
|
2731
|
-
|
|
2748
|
+
val value = (if (step != null) minimum + ((requested - minimum) / step).roundToInt() * step else requested)
|
|
2749
|
+
.coerceIn(minimum, maximum)
|
|
2750
|
+
progress = if (step != null) {
|
|
2751
|
+
((value - minimum) / step).roundToInt().coerceIn(0, max)
|
|
2752
|
+
} else {
|
|
2753
|
+
(((value - minimum) / (maximum - minimum)) * max).roundToInt().coerceIn(0, max)
|
|
2754
|
+
}
|
|
2732
2755
|
if (step != null) screen.sliderSteps[this] = step else screen.sliderSteps.remove(this)
|
|
2733
2756
|
progressTintList = props.optString("minimumTrackTintColor").takeIf { it.isNotBlank() }
|
|
2734
2757
|
?.let { ColorStateList.valueOf(color(it, Color.WHITE)) }
|
|
@@ -2744,25 +2767,19 @@ class MainActivity : AppCompatActivity() {
|
|
|
2744
2767
|
setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
|
|
2745
2768
|
override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) {
|
|
2746
2769
|
val current = screen.handlers[seekBar] ?: return
|
|
2747
|
-
val raw = minimum + (maximum - minimum) * progress.toDouble() / max.toDouble()
|
|
2748
2770
|
val step = screen.sliderSteps[seekBar]
|
|
2749
|
-
val value = if (step != null) minimum +
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
.roundToInt().coerceIn(0, max)
|
|
2753
|
-
if (seekBar.progress != snappedProgress) {
|
|
2754
|
-
seekBar.progress = snappedProgress
|
|
2755
|
-
return
|
|
2756
|
-
}
|
|
2757
|
-
}
|
|
2771
|
+
val value = (if (step != null) minimum + progress * step else
|
|
2772
|
+
minimum + (maximum - minimum) * progress.toDouble() / max.toDouble())
|
|
2773
|
+
.coerceIn(minimum, maximum)
|
|
2758
2774
|
send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject().put("value", value)))
|
|
2759
2775
|
}
|
|
2760
2776
|
override fun onStartTrackingTouch(seekBar: SeekBar) {}
|
|
2761
2777
|
override fun onStopTrackingTouch(seekBar: SeekBar) {
|
|
2762
2778
|
val current = screen.sliderCompleteHandlers[seekBar] ?: return
|
|
2763
|
-
val raw = minimum + (maximum - minimum) * seekBar.progress.toDouble() / max.toDouble()
|
|
2764
2779
|
val step = screen.sliderSteps[seekBar]
|
|
2765
|
-
val value = if (step != null) minimum +
|
|
2780
|
+
val value = (if (step != null) minimum + seekBar.progress * step else
|
|
2781
|
+
minimum + (maximum - minimum) * seekBar.progress.toDouble() / max.toDouble())
|
|
2782
|
+
.coerceIn(minimum, maximum)
|
|
2766
2783
|
send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject().put("value", value)))
|
|
2767
2784
|
}
|
|
2768
2785
|
})
|
package/dist/api/mobile.d.ts
CHANGED
|
@@ -1375,12 +1375,14 @@ export declare const pushNotifications: {
|
|
|
1375
1375
|
onReceive(callback: (data: Record<string, unknown>) => void): () => void;
|
|
1376
1376
|
};
|
|
1377
1377
|
export type HealthDataType = 'steps' | 'heartRate' | 'activeEnergy' | 'distance' | 'workouts' | 'restingHeartRate' | 'heartRateVariability' | 'bodyMass' | 'sleep'
|
|
1378
|
-
/** Food logged by any app, per day: kcal eaten, then grams of protein, carbohydrate and fat.
|
|
1378
|
+
/** Food logged by any app, per day: kcal eaten, then grams of protein, carbohydrate and fat. */
|
|
1379
1379
|
| 'dietaryEnergy' | 'dietaryProtein' | 'dietaryCarbohydrates' | 'dietaryFat'
|
|
1380
1380
|
/** Mindful minutes (meditation, breathing): read with getMindfulSessions, written with saveMindfulSession. */
|
|
1381
1381
|
| 'mindful';
|
|
1382
1382
|
/** The types with one value per day. `sleep` is hours asleep, counted on the day you woke. */
|
|
1383
1383
|
export type HealthDailyType = Exclude<HealthDataType, 'workouts' | 'mindful'>;
|
|
1384
|
+
/** Quantity types that can be written with `health.saveData()`. */
|
|
1385
|
+
export type HealthQuantityType = Exclude<HealthDataType, 'workouts' | 'sleep' | 'mindful'>;
|
|
1384
1386
|
export interface HealthAuthorizationOptions {
|
|
1385
1387
|
/** `false` asks to read only. Default: also allowed to save workouts. */
|
|
1386
1388
|
write?: boolean;
|
|
@@ -1393,6 +1395,10 @@ export interface HealthDataResult {
|
|
|
1393
1395
|
value: number;
|
|
1394
1396
|
unit: string;
|
|
1395
1397
|
}
|
|
1398
|
+
export interface HealthDataSaveResult {
|
|
1399
|
+
/** HealthKit's UUID for the saved quantity sample. */
|
|
1400
|
+
id: string;
|
|
1401
|
+
}
|
|
1396
1402
|
export type HealthWorkoutType = 'running' | 'walking' | 'hiking' | 'cycling';
|
|
1397
1403
|
export interface HealthWorkoutLocation {
|
|
1398
1404
|
latitude: number;
|
|
@@ -1530,7 +1536,7 @@ export interface HealthMindfulSample {
|
|
|
1530
1536
|
deviceName?: string;
|
|
1531
1537
|
}
|
|
1532
1538
|
/** The health reads and writes a host may or may not have, depending on the app build it runs in. */
|
|
1533
|
-
export type HealthReadMethod = 'getWorkouts' | 'getDailyStatistics' | 'getHeartRateSamples' | 'getSleepIntervals' | 'saveMindfulSession' | 'getMindfulSessions';
|
|
1539
|
+
export type HealthReadMethod = 'getWorkouts' | 'getDailyStatistics' | 'getHeartRateSamples' | 'getSleepIntervals' | 'saveData' | 'saveMindfulSession' | 'getMindfulSessions';
|
|
1534
1540
|
export declare const health: {
|
|
1535
1541
|
/**
|
|
1536
1542
|
* Whether this app build answers `method`. An app installed before a read
|
|
@@ -1544,6 +1550,8 @@ export declare const health: {
|
|
|
1544
1550
|
*/
|
|
1545
1551
|
requestAuthorization(types: HealthDataType[], options?: HealthAuthorizationOptions): Promise<boolean>;
|
|
1546
1552
|
getData(type: HealthDataType, options?: HealthDataOptions): Promise<HealthDataResult>;
|
|
1553
|
+
/** Save one quantity sample, such as a meal's dietary energy or macros. iOS only for now. */
|
|
1554
|
+
saveData(type: HealthQuantityType, value: number, options?: HealthDataOptions): Promise<HealthDataSaveResult>;
|
|
1547
1555
|
saveWorkout(workout: HealthWorkout): Promise<HealthWorkoutResult>;
|
|
1548
1556
|
/** Workouts in Apple Health, newest first. iOS only for now. */
|
|
1549
1557
|
getWorkouts(options?: HealthWorkoutQuery): Promise<HealthWorkoutSample[]>;
|
package/dist/cli.js
CHANGED
|
@@ -3303,7 +3303,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
|
|
|
3303
3303
|
`);
|
|
3304
3304
|
}
|
|
3305
3305
|
// package.json
|
|
3306
|
-
var version = "0.0.
|
|
3306
|
+
var version = "0.0.164";
|
|
3307
3307
|
|
|
3308
3308
|
// bin/cli.ts
|
|
3309
3309
|
var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
|
package/dist/index.cjs
CHANGED
package/dist/index.js
CHANGED
|
@@ -738,6 +738,18 @@ final class CraftChrome: ObservableObject {
|
|
|
738
738
|
emit("craftTabSelect", ["id": tab.id])
|
|
739
739
|
}
|
|
740
740
|
|
|
741
|
+
/// A completed horizontal swipe selects the adjacent tab. The shell owns
|
|
742
|
+
/// this path so it works over both native screens and the shared WebView.
|
|
743
|
+
/// The gesture recognizer yields before calling this for a screen-owned
|
|
744
|
+
/// horizontal scroll or custom pager.
|
|
745
|
+
func swipe(to id: String) {
|
|
746
|
+
guard id != selected, tabs.contains(where: { $0.id == id }) else { return }
|
|
747
|
+
withAnimation(Self.motion(.spring(response: 0.32, dampingFraction: 0.86))) { selected = id }
|
|
748
|
+
UISelectionFeedbackGenerator().selectionChanged()
|
|
749
|
+
guard CraftHybrid.shared.tabTapped(id, reselect: false) else { return }
|
|
750
|
+
emit("craftTabSelect", ["id": id])
|
|
751
|
+
}
|
|
752
|
+
|
|
741
753
|
/// The animation, or none with Reduce Motion on: the selection jumps to
|
|
742
754
|
/// its tab rather than springing there, as UIKit's own controls do.
|
|
743
755
|
static func motion(_ animation: Animation) -> Animation? {
|
|
@@ -2514,6 +2526,21 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
2514
2526
|
} else {
|
|
2515
2527
|
rejectCallback(callbackId, error: "HealthKit is disabled", code: "CAPABILITY_DISABLED")
|
|
2516
2528
|
}
|
|
2529
|
+
case "saveHealthData":
|
|
2530
|
+
if config.enableHealthKit {
|
|
2531
|
+
guard let dataType = body["type"] as? String,
|
|
2532
|
+
let value = (body["value"] as? NSNumber)?.doubleValue else {
|
|
2533
|
+
rejectCallback(callbackId, error: "saveHealthData was called without a type and numeric value", code: "INVALID_ARGUMENT")
|
|
2534
|
+
break
|
|
2535
|
+
}
|
|
2536
|
+
CraftHealth.shared.saveData(
|
|
2537
|
+
type: dataType,
|
|
2538
|
+
value: value,
|
|
2539
|
+
options: body
|
|
2540
|
+
) { [weak self] in self?.answer(callbackId, $0) }
|
|
2541
|
+
} else {
|
|
2542
|
+
rejectCallback(callbackId, error: "HealthKit is disabled", code: "CAPABILITY_DISABLED")
|
|
2543
|
+
}
|
|
2517
2544
|
case "saveHealthWorkout":
|
|
2518
2545
|
if config.enableHealthKit {
|
|
2519
2546
|
saveHealthWorkout(body: body, callbackId: callbackId)
|
|
@@ -5077,6 +5104,10 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
5077
5104
|
options = options || {};
|
|
5078
5105
|
return craft._invoke('getHealthData', {type: type, startDate: options.startDate, endDate: options.endDate});
|
|
5079
5106
|
},
|
|
5107
|
+
saveData: function(type, value, options) {
|
|
5108
|
+
options = options || {};
|
|
5109
|
+
return craft._invoke('saveHealthData', {type: type, value: value, startDate: options.startDate, endDate: options.endDate});
|
|
5110
|
+
},
|
|
5080
5111
|
saveWorkout: function(workout) { return craft._invoke('saveHealthWorkout', workout || {}); },
|
|
5081
5112
|
getWorkouts: function(options) {
|
|
5082
5113
|
options = options || {};
|
|
@@ -32,6 +32,13 @@ final class CraftHealth {
|
|
|
32
32
|
case "getData":
|
|
33
33
|
guard let type = args.first as? String else { return completion(.failure(CraftHealthError(message: "getData needs a type", code: "INVALID_ARGUMENT"))) }
|
|
34
34
|
getData(type: type, startDate: options(1)["startDate"] as? Double, endDate: options(1)["endDate"] as? Double, completion: completion)
|
|
35
|
+
case "saveData":
|
|
36
|
+
guard let type = args.first as? String,
|
|
37
|
+
args.count > 1,
|
|
38
|
+
let value = (args[1] as? NSNumber)?.doubleValue else {
|
|
39
|
+
return completion(.failure(CraftHealthError(message: "saveData needs a type and numeric value", code: "INVALID_ARGUMENT")))
|
|
40
|
+
}
|
|
41
|
+
saveData(type: type, value: value, options: options(2), completion: completion)
|
|
35
42
|
case "saveWorkout":
|
|
36
43
|
saveWorkout(body: options(0), completion: completion)
|
|
37
44
|
case "getWorkouts":
|
|
@@ -120,12 +127,15 @@ final class CraftHealth {
|
|
|
120
127
|
}
|
|
121
128
|
case "dietaryEnergy", "dietaryProtein", "dietaryCarbohydrates", "dietaryFat":
|
|
122
129
|
// What a food log (MyFitnessPal, Lose It!, Cronometer...)
|
|
123
|
-
// writes to Apple Health
|
|
130
|
+
// writes to Apple Health. Request the write grant too unless
|
|
131
|
+
// the caller explicitly asked for a read-only authorization.
|
|
124
132
|
if let dietaryType = Self.healthQuantity(type)?.type {
|
|
125
133
|
readTypes.insert(dietaryType)
|
|
134
|
+
if !readOnly { shareTypes.insert(dietaryType) }
|
|
126
135
|
}
|
|
127
136
|
default:
|
|
128
|
-
|
|
137
|
+
completion(.failure(CraftHealthError(message: "Unknown health data type: \(type)", code: "INVALID_ARGUMENT")))
|
|
138
|
+
return
|
|
129
139
|
}
|
|
130
140
|
}
|
|
131
141
|
|
|
@@ -179,6 +189,42 @@ final class CraftHealth {
|
|
|
179
189
|
healthStore.execute(query)
|
|
180
190
|
}
|
|
181
191
|
|
|
192
|
+
/// Save one quantity sample, primarily a food-log entry. Dates are epoch
|
|
193
|
+
/// milliseconds; an omitted range records an instantaneous sample now.
|
|
194
|
+
func saveData(type: String, value: Double, options: [String: Any], completion: @escaping Completion) {
|
|
195
|
+
guard let healthStore = healthStore else {
|
|
196
|
+
completion(.failure(CraftHealthError(message: "HealthKit not available")))
|
|
197
|
+
return
|
|
198
|
+
}
|
|
199
|
+
guard value.isFinite, value >= 0 else {
|
|
200
|
+
completion(.failure(CraftHealthError(message: "saveData needs a finite, non-negative value", code: "INVALID_ARGUMENT")))
|
|
201
|
+
return
|
|
202
|
+
}
|
|
203
|
+
guard let spec = Self.healthQuantity(type), let qType = spec.type else {
|
|
204
|
+
completion(.failure(CraftHealthError(message: "Unknown health data type: \(type)", code: "INVALID_ARGUMENT")))
|
|
205
|
+
return
|
|
206
|
+
}
|
|
207
|
+
let startValue = options["startDate"] as? Double ?? Date().timeIntervalSince1970 * 1000
|
|
208
|
+
let endValue = options["endDate"] as? Double ?? startValue
|
|
209
|
+
guard startValue.isFinite, endValue.isFinite, endValue >= startValue else {
|
|
210
|
+
completion(.failure(CraftHealthError(message: "saveData needs a valid date range", code: "INVALID_ARGUMENT")))
|
|
211
|
+
return
|
|
212
|
+
}
|
|
213
|
+
let sample = HKQuantitySample(
|
|
214
|
+
type: qType,
|
|
215
|
+
quantity: HKQuantity(unit: spec.unit, doubleValue: value),
|
|
216
|
+
start: Date(timeIntervalSince1970: startValue / 1000),
|
|
217
|
+
end: Date(timeIntervalSince1970: endValue / 1000)
|
|
218
|
+
)
|
|
219
|
+
healthStore.save(sample) { success, error in
|
|
220
|
+
if success {
|
|
221
|
+
completion(.success(["id": sample.uuid.uuidString]))
|
|
222
|
+
} else {
|
|
223
|
+
completion(.failure(CraftHealthError(message: error?.localizedDescription ?? "Health data could not be saved")))
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
182
228
|
func saveWorkout(body: [String: Any], completion: @escaping Completion) {
|
|
183
229
|
guard let healthStore = healthStore else {
|
|
184
230
|
completion(.failure(CraftHealthError(message: "HealthKit not available")))
|
|
@@ -1004,6 +1004,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1004
1004
|
private var openingWeb = false
|
|
1005
1005
|
private var firstFrameLogged = false
|
|
1006
1006
|
private var tabBarHeight: CGFloat = 0
|
|
1007
|
+
private var tabSwipeRecognizers: [UISwipeGestureRecognizer] = []
|
|
1007
1008
|
|
|
1008
1009
|
init(config: CraftConfig) {
|
|
1009
1010
|
self.config = config
|
|
@@ -1033,6 +1034,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1033
1034
|
let tabs = hybrid.tabIds
|
|
1034
1035
|
let start = CraftHybridRoutes.tab(owning: hybrid.startPath, among: tabs) ?? tabs.first ?? ""
|
|
1035
1036
|
show(tab: start, rootPath: hybrid.startPath)
|
|
1037
|
+
installTabSwipeGestures()
|
|
1036
1038
|
if hybrid.hasWeb && hybrid.startsNative {
|
|
1037
1039
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in self?.attachWeb() }
|
|
1038
1040
|
}
|
|
@@ -1043,6 +1045,42 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1043
1045
|
|
|
1044
1046
|
private var webAttached = false
|
|
1045
1047
|
|
|
1048
|
+
private func installTabSwipeGestures() {
|
|
1049
|
+
guard tabSwipeRecognizers.isEmpty else { return }
|
|
1050
|
+
for direction in [UISwipeGestureRecognizer.Direction.left, .right] {
|
|
1051
|
+
let recognizer = UISwipeGestureRecognizer(target: self, action: #selector(tabSwipeRecognized(_:)))
|
|
1052
|
+
recognizer.direction = direction
|
|
1053
|
+
recognizer.delegate = self
|
|
1054
|
+
recognizer.cancelsTouchesInView = false
|
|
1055
|
+
recognizer.delaysTouchesBegan = false
|
|
1056
|
+
view.addGestureRecognizer(recognizer)
|
|
1057
|
+
tabSwipeRecognizers.append(recognizer)
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
@objc private func tabSwipeRecognized(_ recognizer: UISwipeGestureRecognizer) {
|
|
1062
|
+
let tabs = hybrid.tabIds
|
|
1063
|
+
guard let index = tabs.firstIndex(of: currentTab), tabs.count > 1 else { return }
|
|
1064
|
+
let next = recognizer.direction == .left ? index + 1 : index - 1
|
|
1065
|
+
guard tabs.indices.contains(next) else { return }
|
|
1066
|
+
CraftChrome.shared.swipe(to: tabs[next])
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
private func horizontalScrollAt(_ point: CGPoint, in root: UIView) -> Bool {
|
|
1070
|
+
let local = root.convert(point, from: view)
|
|
1071
|
+
guard root.bounds.contains(local), let hit = root.hitTest(local, with: nil) else { return false }
|
|
1072
|
+
var candidate: UIView? = hit
|
|
1073
|
+
while let current = candidate {
|
|
1074
|
+
if let scroll = current as? UIScrollView,
|
|
1075
|
+
scroll.isScrollEnabled,
|
|
1076
|
+
scroll.alwaysBounceHorizontal || scroll.contentSize.width > scroll.bounds.width + 1 {
|
|
1077
|
+
return true
|
|
1078
|
+
}
|
|
1079
|
+
candidate = current.superview
|
|
1080
|
+
}
|
|
1081
|
+
return false
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1046
1084
|
/// The web view joins the screen, behind the native screens, and loads.
|
|
1047
1085
|
private func attachWeb() {
|
|
1048
1086
|
guard let webHost, !webAttached else { return }
|
|
@@ -1481,8 +1519,19 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1481
1519
|
}
|
|
1482
1520
|
|
|
1483
1521
|
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
|
1522
|
+
if tabSwipeRecognizers.contains(where: { $0 === gestureRecognizer }) {
|
|
1523
|
+
guard hybrid.tabIds.count > 1, let top = visibleStack?.topViewController else { return false }
|
|
1524
|
+
if let native = top as? CraftNativeScreenController,
|
|
1525
|
+
!native.tabSwipeEnabled || !native.contentSwipeBack { return false }
|
|
1526
|
+
return !horizontalScrollAt(gestureRecognizer.location(in: view), in: top.view)
|
|
1527
|
+
}
|
|
1484
1528
|
guard let nav = visibleStack, isPopRecognizer(gestureRecognizer),
|
|
1485
1529
|
nav.viewControllers.count > 1, nav.transitionCoordinator == nil else { return false }
|
|
1530
|
+
if let native = nav.topViewController as? CraftNativeScreenController,
|
|
1531
|
+
gestureRecognizer !== nav.interactivePopGestureRecognizer,
|
|
1532
|
+
!native.contentSwipeBack {
|
|
1533
|
+
return false
|
|
1534
|
+
}
|
|
1486
1535
|
if let native = nav.topViewController as? CraftNativeScreenController,
|
|
1487
1536
|
gestureRecognizer !== nav.interactivePopGestureRecognizer,
|
|
1488
1537
|
nativeScreenContainsHorizontalScroll(native) {
|
|
@@ -40,7 +40,7 @@ enum CraftNativeActions {
|
|
|
40
40
|
static func capabilities(config: CraftConfig) -> [String] {
|
|
41
41
|
// `speech`, `audio` (background sound) and `eventStream` (a fetch read
|
|
42
42
|
// as it arrives) need nothing the app has to declare.
|
|
43
|
-
var values = ["device", "clipboard", "haptics", "storage", "lifecycle", "fetch", "dialog", "speech", "audio", "eventStream", "linking"]
|
|
43
|
+
var values = ["device", "clipboard", "haptics", "storage", "lifecycle", "fetch", "dialog", "speech", "audio", "eventStream", "linking", "badge"]
|
|
44
44
|
if config.enableBiometric { values.append("biometric") }
|
|
45
45
|
if config.enableSecureStorage { values.append("secureStorage") }
|
|
46
46
|
if config.enableLocalDatabase { values.append("database") }
|
|
@@ -76,6 +76,9 @@ enum CraftNativeActions {
|
|
|
76
76
|
// runs out of process and asks no permission. Taking a photo needs
|
|
77
77
|
// the camera, which the app declares.
|
|
78
78
|
values.append("files")
|
|
79
|
+
// Video and audio kept on the phone for offline (CraftNativeMedia),
|
|
80
|
+
// which the native Video element plays in place of the address.
|
|
81
|
+
values.append("media")
|
|
79
82
|
if config.enableCamera { values.append("camera") }
|
|
80
83
|
return values
|
|
81
84
|
}
|
|
@@ -190,6 +193,19 @@ enum CraftNativeActions {
|
|
|
190
193
|
return
|
|
191
194
|
case ("Files", "remove"):
|
|
192
195
|
sync = .success(CraftNativeFiles.remove(args.first as? String ?? ""))
|
|
196
|
+
case ("Media", "keep"):
|
|
197
|
+
keepMedia(requestToken: requestToken, args: args, completion: completion)
|
|
198
|
+
return
|
|
199
|
+
case ("Media", "path"):
|
|
200
|
+
sync = .success(CraftNativeMedia.local(args.first as? String ?? "")?.path ?? NSNull())
|
|
201
|
+
case ("Media", "prune"):
|
|
202
|
+
let keep = (args.first as? [Any] ?? []).compactMap { $0 as? String }
|
|
203
|
+
DispatchQueue.global(qos: .utility).async {
|
|
204
|
+
complete(requestToken, .success(CraftNativeMedia.prune(keep: keep)), completion)
|
|
205
|
+
}
|
|
206
|
+
return
|
|
207
|
+
case ("Media", "usage"):
|
|
208
|
+
sync = .success(CraftNativeMedia.usage())
|
|
193
209
|
case ("Lifecycle", "getState"):
|
|
194
210
|
sync = .success(currentAppState())
|
|
195
211
|
case ("DeepLinks", "getInitialURL"):
|
|
@@ -440,6 +456,19 @@ enum CraftNativeActions {
|
|
|
440
456
|
complete(requestToken, .success(true), completion)
|
|
441
457
|
}
|
|
442
458
|
return
|
|
459
|
+
case ("Badge", "set"):
|
|
460
|
+
guard let value = args.first as? NSNumber,
|
|
461
|
+
value.doubleValue.isFinite,
|
|
462
|
+
value.doubleValue >= 0,
|
|
463
|
+
value.doubleValue.rounded() == value.doubleValue else {
|
|
464
|
+
complete(requestToken, failure("INVALID_ARGUMENT", "Badge.set needs a non-negative whole number"), completion)
|
|
465
|
+
return
|
|
466
|
+
}
|
|
467
|
+
setBadgeCount(value.intValue) { complete(requestToken, $0, completion) }
|
|
468
|
+
return
|
|
469
|
+
case ("Badge", "clear"):
|
|
470
|
+
setBadgeCount(0) { complete(requestToken, $0, completion) }
|
|
471
|
+
return
|
|
443
472
|
case ("Notifications", _):
|
|
444
473
|
guard config.enableLocalNotifications else {
|
|
445
474
|
complete(requestToken, failure("CAPABILITY_DISABLED", "Local notifications are disabled"), completion)
|
|
@@ -614,6 +643,63 @@ enum CraftNativeActions {
|
|
|
614
643
|
if !alreadyCancelled { task.resume() }
|
|
615
644
|
}
|
|
616
645
|
|
|
646
|
+
/// `craft.media.keep`: the file at an address kept on the phone for
|
|
647
|
+
/// offline, fetched once (a copy already there answers at once, and two
|
|
648
|
+
/// screens asking at the same time share one download). A refused answer
|
|
649
|
+
/// (not 2xx) keeps nothing and says its status. Cancelled like fetch.
|
|
650
|
+
private static func keepMedia(
|
|
651
|
+
requestToken: String,
|
|
652
|
+
args: [Any],
|
|
653
|
+
completion: @escaping (Result<Any, CraftNativeActionError>) -> Void
|
|
654
|
+
) {
|
|
655
|
+
guard let address = args.first as? String,
|
|
656
|
+
let url = URL(string: address),
|
|
657
|
+
let scheme = url.scheme?.lowercased(), scheme == "https" || scheme == "http",
|
|
658
|
+
url.host?.isEmpty == false else {
|
|
659
|
+
complete(requestToken, failure("INVALID_ARGUMENT", "media.keep needs an absolute http or https URL"), completion)
|
|
660
|
+
return
|
|
661
|
+
}
|
|
662
|
+
if let kept = CraftNativeMedia.local(address) {
|
|
663
|
+
complete(requestToken, .success(CraftNativeMedia.describe(address, kept)), completion)
|
|
664
|
+
return
|
|
665
|
+
}
|
|
666
|
+
let options = args.count > 1 ? args[1] as? [String: Any] ?? [:] : [:]
|
|
667
|
+
var request = URLRequest(url: url, timeoutInterval: craftNativeFetchTimeoutSeconds)
|
|
668
|
+
request.httpMethod = "GET"
|
|
669
|
+
if let headers = options["headers"] as? [String: Any] {
|
|
670
|
+
for (name, value) in headers {
|
|
671
|
+
guard let value = value as? String else {
|
|
672
|
+
complete(requestToken, failure("INVALID_ARGUMENT", "media.keep header values must be strings"), completion)
|
|
673
|
+
return
|
|
674
|
+
}
|
|
675
|
+
request.setValue(value, forHTTPHeaderField: name)
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
if CraftNativeMedia.join(address, { complete(requestToken, $0, completion) }) { return }
|
|
679
|
+
let task = fetchSession.downloadTask(with: request) { location, response, error in
|
|
680
|
+
cancellationLock.lock()
|
|
681
|
+
fetchTasks.removeValue(forKey: requestToken)
|
|
682
|
+
cancellationLock.unlock()
|
|
683
|
+
if let error = error {
|
|
684
|
+
let code = (error as? URLError)?.code == .timedOut ? "TIMEOUT" : "NETWORK_ERROR"
|
|
685
|
+
CraftNativeMedia.finish(address, failure(code, error.localizedDescription))
|
|
686
|
+
return
|
|
687
|
+
}
|
|
688
|
+
guard let http = response as? HTTPURLResponse, let location else {
|
|
689
|
+
CraftNativeMedia.finish(address, failure("NETWORK_ERROR", "media.keep received a response that is not HTTP"))
|
|
690
|
+
return
|
|
691
|
+
}
|
|
692
|
+
// The system deletes `location` once this returns: move it now.
|
|
693
|
+
CraftNativeMedia.finish(address, CraftNativeMedia.store(address, at: location, response: http))
|
|
694
|
+
}
|
|
695
|
+
cancellationLock.lock()
|
|
696
|
+
let alreadyCancelled = cancelledRequests.contains(requestToken)
|
|
697
|
+
if !alreadyCancelled { fetchTasks[requestToken] = task }
|
|
698
|
+
cancellationLock.unlock()
|
|
699
|
+
if alreadyCancelled { CraftNativeMedia.finish(address, failure("CANCELLED", "media.keep was cancelled")) }
|
|
700
|
+
else { task.resume() }
|
|
701
|
+
}
|
|
702
|
+
|
|
617
703
|
private static func claimInitialDeepLink() -> URL? {
|
|
618
704
|
cancellationLock.lock()
|
|
619
705
|
defer { cancellationLock.unlock() }
|
|
@@ -920,6 +1006,30 @@ enum CraftNativeActions {
|
|
|
920
1006
|
}
|
|
921
1007
|
}
|
|
922
1008
|
|
|
1009
|
+
/// Set the app icon badge without requesting notification authorization.
|
|
1010
|
+
/// Native screens use this same permission-free path as the page bridge.
|
|
1011
|
+
private static func setBadgeCount(
|
|
1012
|
+
_ count: Int,
|
|
1013
|
+
completion: @escaping (Result<Any, CraftNativeActionError>) -> Void
|
|
1014
|
+
) {
|
|
1015
|
+
DispatchQueue.main.async {
|
|
1016
|
+
if #available(iOS 16.0, *) {
|
|
1017
|
+
UNUserNotificationCenter.current().setBadgeCount(count) { error in
|
|
1018
|
+
DispatchQueue.main.async {
|
|
1019
|
+
if let error {
|
|
1020
|
+
completion(.failure(CraftNativeActionError(code: "NATIVE_CALL_FAILED", message: error.localizedDescription)))
|
|
1021
|
+
} else {
|
|
1022
|
+
completion(.success(true))
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
} else {
|
|
1027
|
+
UIApplication.shared.applicationIconBadgeNumber = count
|
|
1028
|
+
completion(.success(true))
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
|
|
923
1033
|
static func deepLinkData(_ url: URL) -> [String: Any] {
|
|
924
1034
|
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
|
925
1035
|
return [
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import CryptoKit
|
|
1
2
|
import Foundation
|
|
2
3
|
import ImageIO
|
|
3
4
|
import PhotosUI
|
|
@@ -375,3 +376,109 @@ enum CraftNativeFiles {
|
|
|
375
376
|
return ["ok": false, "status": response.statusCode, "body": text]
|
|
376
377
|
}
|
|
377
378
|
}
|
|
379
|
+
|
|
380
|
+
// `craft.media` on a native screen: video and audio kept on the phone for
|
|
381
|
+
// when there is no signal (a gym's basement, a plane). Not tmp/craft-files,
|
|
382
|
+
// which iOS and the sweep above both empty: Application Support/craft-media,
|
|
383
|
+
// left out of iCloud backups because every file can be fetched again. A file
|
|
384
|
+
// is named by its address's SHA-256, so the native `Video` element finds the
|
|
385
|
+
// copy of the URL it was given and plays it instead, with no lookup in
|
|
386
|
+
// JavaScript. The screen decides what to keep (`keep`) and what to drop
|
|
387
|
+
// (`prune`); nothing is evicted behind its back.
|
|
388
|
+
enum CraftNativeMedia {
|
|
389
|
+
static var root: URL {
|
|
390
|
+
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
|
|
391
|
+
?? FileManager.default.temporaryDirectory
|
|
392
|
+
return base.appendingPathComponent("craft-media", isDirectory: true)
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
private static let lock = NSLock()
|
|
396
|
+
/// Addresses being fetched now, so a second `keep` waits for the first.
|
|
397
|
+
private static var inFlight: [String: [(Result<Any, CraftNativeActionError>) -> Void]] = [:]
|
|
398
|
+
|
|
399
|
+
/// Where `address`'s copy lives (whether or not it is there yet). The
|
|
400
|
+
/// extension is the address's, because AVPlayer reads the type from it.
|
|
401
|
+
static func file(for address: String) -> URL {
|
|
402
|
+
let digest = SHA256.hash(data: Data(address.utf8)).map { String(format: "%02x", $0) }.joined()
|
|
403
|
+
let ext = URL(string: address)?.pathExtension.lowercased() ?? ""
|
|
404
|
+
let safe = ext.range(of: "^[a-z0-9]{1,5}$", options: .regularExpression) != nil ? ext : "bin"
|
|
405
|
+
return root.appendingPathComponent("\(digest).\(safe)")
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/// The copy of `address` on this phone, or nil.
|
|
409
|
+
static func local(_ address: String) -> URL? {
|
|
410
|
+
guard !address.isEmpty else { return nil }
|
|
411
|
+
let url = file(for: address)
|
|
412
|
+
return FileManager.default.fileExists(atPath: url.path) ? url : nil
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
static func describe(_ address: String, _ url: URL) -> [String: Any] {
|
|
416
|
+
let size = (try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0
|
|
417
|
+
return ["ok": true, "url": address, "path": url.path, "size": size]
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/// Joins a fetch of `address` already under way; true when one was.
|
|
421
|
+
static func join(_ address: String, _ done: @escaping (Result<Any, CraftNativeActionError>) -> Void) -> Bool {
|
|
422
|
+
lock.lock()
|
|
423
|
+
defer { lock.unlock() }
|
|
424
|
+
if inFlight[address] != nil {
|
|
425
|
+
inFlight[address]!.append(done)
|
|
426
|
+
return true
|
|
427
|
+
}
|
|
428
|
+
inFlight[address] = [done]
|
|
429
|
+
return false
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
static func finish(_ address: String, _ result: Result<Any, CraftNativeActionError>) {
|
|
433
|
+
lock.lock()
|
|
434
|
+
let waiting = inFlight.removeValue(forKey: address) ?? []
|
|
435
|
+
lock.unlock()
|
|
436
|
+
for done in waiting { done(result) }
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/// A finished download moved into place (atomically, so a half-written
|
|
440
|
+
/// file is never played), or a refusal with the server's status.
|
|
441
|
+
static func store(_ address: String, at location: URL, response: HTTPURLResponse) -> Result<Any, CraftNativeActionError> {
|
|
442
|
+
guard (200..<300).contains(response.statusCode) else {
|
|
443
|
+
return .success(["ok": false, "url": address, "status": response.statusCode])
|
|
444
|
+
}
|
|
445
|
+
let fm = FileManager.default
|
|
446
|
+
do {
|
|
447
|
+
var folder = root
|
|
448
|
+
if !fm.fileExists(atPath: folder.path) {
|
|
449
|
+
try fm.createDirectory(at: folder, withIntermediateDirectories: true)
|
|
450
|
+
var values = URLResourceValues()
|
|
451
|
+
values.isExcludedFromBackup = true
|
|
452
|
+
try? folder.setResourceValues(values)
|
|
453
|
+
}
|
|
454
|
+
let target = file(for: address)
|
|
455
|
+
let staging = root.appendingPathComponent(UUID().uuidString + ".part")
|
|
456
|
+
try fm.moveItem(at: location, to: staging)
|
|
457
|
+
_ = try? fm.removeItem(at: target)
|
|
458
|
+
try fm.moveItem(at: staging, to: target)
|
|
459
|
+
return .success(describe(address, target))
|
|
460
|
+
}
|
|
461
|
+
catch {
|
|
462
|
+
return .failure(CraftNativeActionError(code: "IO_ERROR", message: "media.keep could not store the file: \(error.localizedDescription)"))
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/// Removes every copy whose address is not in `keep`; answers how many.
|
|
467
|
+
static func prune(keep: [String]) -> Int {
|
|
468
|
+
let wanted = Set(keep.map { file(for: $0).lastPathComponent })
|
|
469
|
+
let files = (try? FileManager.default.contentsOfDirectory(at: root, includingPropertiesForKeys: nil)) ?? []
|
|
470
|
+
var removed = 0
|
|
471
|
+
for url in files where !wanted.contains(url.lastPathComponent) {
|
|
472
|
+
if (try? FileManager.default.removeItem(at: url)) != nil { removed += 1 }
|
|
473
|
+
}
|
|
474
|
+
return removed
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/// What the kept copies come to, in bytes, and how many there are.
|
|
478
|
+
static func usage() -> [String: Any] {
|
|
479
|
+
let files = (try? FileManager.default.contentsOfDirectory(at: root, includingPropertiesForKeys: [.fileSizeKey])) ?? []
|
|
480
|
+
let kept = files.filter { $0.pathExtension != "part" }
|
|
481
|
+
let bytes = kept.reduce(0) { $0 + ((try? $1.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0) }
|
|
482
|
+
return ["files": kept.count, "bytes": bytes]
|
|
483
|
+
}
|
|
484
|
+
}
|
|
@@ -341,8 +341,19 @@ private final class CraftNativeVideoView: UIView {
|
|
|
341
341
|
detachPlayer()
|
|
342
342
|
sourceURI = uri
|
|
343
343
|
loops = false
|
|
344
|
-
|
|
344
|
+
// The copy `craft.media.keep` put on the phone plays in place of
|
|
345
|
+
// the address, so a kept video plays with no signal.
|
|
346
|
+
let kept = CraftNativeMedia.local(uri)
|
|
347
|
+
guard let url = kept ?? URL(string: uri), !uri.isEmpty, kept != nil || url.scheme != nil else {
|
|
345
348
|
playerController.player = nil
|
|
349
|
+
// A path with no scheme ("/videos/a.mp4") has nowhere to load
|
|
350
|
+
// from on a native screen. Say so rather than stay black.
|
|
351
|
+
if !uri.isEmpty {
|
|
352
|
+
DispatchQueue.main.async { [weak self] in
|
|
353
|
+
guard let self, self.sourceURI == uri else { return }
|
|
354
|
+
self.fire("onError", ["message": "The video address is not a full URL: \(uri)"])
|
|
355
|
+
}
|
|
356
|
+
}
|
|
346
357
|
return
|
|
347
358
|
}
|
|
348
359
|
let next = AVPlayer(url: url)
|
|
@@ -1110,6 +1121,10 @@ private class CraftNativeFlowView: UIStackView {
|
|
|
1110
1121
|
}
|
|
1111
1122
|
if start < items.count { lines.append(start..<items.count) }
|
|
1112
1123
|
}
|
|
1124
|
+
// Reconciliation reverses the child order for the requested main
|
|
1125
|
+
// axis. A wrapped flex container must keep its lines in cross-axis
|
|
1126
|
+
// order, so restore the groups without changing each line's order.
|
|
1127
|
+
if reverse && wrap && lines.count > 1 { lines.reverse() }
|
|
1113
1128
|
|
|
1114
1129
|
var lineMains: [CGFloat] = []
|
|
1115
1130
|
var lineCrosses: [CGFloat] = []
|
|
@@ -1922,7 +1937,9 @@ private final class CraftNativeSvgView: UIView {
|
|
|
1922
1937
|
if let fill = style["fill"] as? String ?? props["fill"] as? String { next.fill = fill }
|
|
1923
1938
|
if let stroke = style["stroke"] as? String ?? props["stroke"] as? String { next.stroke = stroke }
|
|
1924
1939
|
if let width = (style["strokeWidth"] as? NSNumber ?? props["strokeWidth"] as? NSNumber) { next.strokeWidth = CGFloat(truncating: width) }
|
|
1925
|
-
|
|
1940
|
+
// Computed style wins over the component prop, matching Android
|
|
1941
|
+
// and the browser's cascade when both provide opacity.
|
|
1942
|
+
if let opacity = style["opacity"] as? NSNumber ?? props["opacity"] as? NSNumber { next.opacity *= CGFloat(truncating: opacity) }
|
|
1926
1943
|
if let cap = props["strokeLinecap"] as? String { next.linecap = cap }
|
|
1927
1944
|
if let join = props["strokeLinejoin"] as? String { next.linejoin = join }
|
|
1928
1945
|
if let dashes = props["strokeDasharray"] as? [Any] { next.dashes = dashes.compactMap { ($0 as? NSNumber).map { CGFloat(truncating: $0) } } }
|
|
@@ -2709,7 +2726,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
2709
2726
|
/// - `craft.secureStorage.getSync(key)`: the Keychain item the web page's
|
|
2710
2727
|
/// `craft.secureStorage.set` wrote.
|
|
2711
2728
|
/// - `craft.navigation.setOptions({ title, largeTitle, hidden, backTitle,
|
|
2712
|
-
/// rightButtons })`; a right button's
|
|
2729
|
+
/// tabBarHidden, contentSwipeBack, tabSwipeEnabled, rightButtons })`; a right button's
|
|
2730
|
+
/// tap is the event `navButton`.
|
|
2713
2731
|
///
|
|
2714
2732
|
/// A runtime that assigns `craft.storage` (or the others) afterwards keeps
|
|
2715
2733
|
/// these: each namespace is a property whose setter adds them back to the
|
|
@@ -2953,6 +2971,13 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
2953
2971
|
private var navigationBarHidden: Bool?
|
|
2954
2972
|
/// The app's tab bar away while this screen shows (`tabBarHidden`).
|
|
2955
2973
|
private var tabBarHidden = false
|
|
2974
|
+
/// Whether iOS 26's content-wide back-swipe may begin on this screen.
|
|
2975
|
+
/// Setting `contentSwipeBack: false` leaves the 32-point edge gesture.
|
|
2976
|
+
private(set) var contentSwipeBack = true
|
|
2977
|
+
/// Whether the shell's horizontal tab switch gesture may begin here.
|
|
2978
|
+
/// This is separate so a screen can keep navigation back while owning its
|
|
2979
|
+
/// own horizontal pager or chart gesture.
|
|
2980
|
+
private(set) var tabSwipeEnabled = true
|
|
2956
2981
|
private var prefersLargeTitle: Bool?
|
|
2957
2982
|
|
|
2958
2983
|
/// Whether this screen asked for a bar of its own through `setOptions`: a
|
|
@@ -2988,6 +3013,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
2988
3013
|
CraftChrome.shared.setHiddenByScreen(hidden)
|
|
2989
3014
|
}
|
|
2990
3015
|
}
|
|
3016
|
+
if let contentSwipeBack = options["contentSwipeBack"] as? Bool {
|
|
3017
|
+
self.contentSwipeBack = contentSwipeBack
|
|
3018
|
+
}
|
|
3019
|
+
if let tabSwipeEnabled = options["tabSwipeEnabled"] as? Bool {
|
|
3020
|
+
self.tabSwipeEnabled = tabSwipeEnabled
|
|
3021
|
+
}
|
|
2991
3022
|
if let hidden = options["hidden"] as? Bool {
|
|
2992
3023
|
navigationBarHidden = hidden
|
|
2993
3024
|
if navigationController?.topViewController === self {
|
package/dist/mobile.js
CHANGED
|
@@ -1385,6 +1385,12 @@ var health = {
|
|
|
1385
1385
|
throw new Error("Health data is unavailable");
|
|
1386
1386
|
return craft.health.getData(type, options);
|
|
1387
1387
|
},
|
|
1388
|
+
async saveData(type, value, options = {}) {
|
|
1389
|
+
const craft = getCraftRoot();
|
|
1390
|
+
if (!craft?.health?.saveData)
|
|
1391
|
+
throw new Error("Saving health data is unavailable");
|
|
1392
|
+
return craft.health.saveData(type, value, options);
|
|
1393
|
+
},
|
|
1388
1394
|
async saveWorkout(workout) {
|
|
1389
1395
|
const craft = getCraftRoot();
|
|
1390
1396
|
if (!craft?.health?.saveWorkout)
|