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.
@@ -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 = (value("opacity")?.toFloatOrNull() ?: 1f) * 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 = 0
397
- private var columnGap = 0
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 = (style.optDouble("gap", 0.0) * density).toInt()
411
- rowGap = (style.optDouble("rowGap", gap.toDouble() / density.toDouble()) * density).toInt()
412
- columnGap = (style.optDouble("columnGap", gap.toDouble() / density.toDouble()) * density).toInt()
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: Int = 0, var cross: Int = 0)
442
- private data class GridLayout(val rows: List<List<Entry>>, val absolute: List<Entry>, val cellWidth: Int, val rowHeights: List<Int>)
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 + (margins[index * 2] + margins[index * 2 + 1]).roundToInt()
491
- else entry.height + (margins[index * 2] + margins[index * 2 + 1]).roundToInt()
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(0, (availableWidth - max(0, gridColumns - 1) * gap) / max(1, gridColumns))
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
- } ?: 0)
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: Int): IntArray {
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 } ?: 0
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.toFloat(), rowGap.toFloat(),
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(0, contentHeight - totalRows - rowGaps).toFloat()
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(0, layout.cellWidth - marginLeft - marginRight)
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) / 2
836
+ val x = (paddingLeft + column * (layout.cellWidth + columnGap) + marginLeft + when (align) {
837
+ "center" -> (availableWidth - childWidth) / 2f
829
838
  "flex-end" -> availableWidth - childWidth
830
- else -> 0
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(0, availableCross - totalCross)
862
- var crossLeading = 0
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.toFloat(), alignContentCode(), lines.map { it.cross.toFloat() }.toFloatArray()
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.roundToInt() }
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 / 2
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
- var crossOffset = (if (orientation == HORIZONTAL) paddingTop else paddingLeft) + crossLeading
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 lineOffset = sharedCross?.first?.get(lineIndex)?.roundToInt()?.let { (if (orientation == HORIZONTAL) paddingTop else paddingLeft) + it } ?: crossOffset
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.toFloat(),
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].roundToInt() + margins
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(0, availableMain - distributedMain)
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, line.cross - (if (orientation == HORIZONTAL) (lp?.topMargin ?: 0) + (lp?.bottomMargin ?: 0) else (lp?.leftMargin ?: 0) + (lp?.rightMargin ?: 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
- line.cross.toFloat(), crossSize.toFloat(), crossLeading.toFloat(), crossTrailing.toFloat(), alignment
951
- )?.roundToInt() ?: when (alignment) {
952
- 1 -> crossLeading + (line.cross - crossLeading - crossTrailing - crossSize) / 2
953
- 2 -> line.cross - crossTrailing - crossSize
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
- val sharedPosition = if (!reverse) sharedPositions?.get(index)?.roundToInt() else null
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 += line.cross + crossBetween
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: Int, count: Int): Pair<Float, Float> {
984
- val available = free.toFloat()
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
- progress = (((value - minimum) / (maximum - minimum)) * max).roundToInt().coerceIn(0, max)
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 + ((raw - minimum) / step).roundToInt() * step else raw
2750
- if (step != null) {
2751
- val snappedProgress = (((value - minimum) / (maximum - minimum)) * max)
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 + ((raw - minimum) / step).roundToInt() * step else raw
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
  })
@@ -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. Read only. */
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.163";
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
@@ -310,7 +310,7 @@ function craftBinaryNotFoundMessage(triedPath) {
310
310
  `);
311
311
  }
312
312
  // package.json
313
- var version = "0.0.163";
313
+ var version = "0.0.164";
314
314
 
315
315
  // src/sdk-version.ts
316
316
  var SDK_VERSION = version;
package/dist/index.js CHANGED
@@ -58,7 +58,7 @@ function craftBinaryNotFoundMessage(triedPath) {
58
58
  `);
59
59
  }
60
60
  // package.json
61
- var version = "0.0.163";
61
+ var version = "0.0.164";
62
62
 
63
63
  // src/sdk-version.ts
64
64
  var SDK_VERSION = version;
@@ -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: read only, never written back.
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
- break
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
- guard let url = URL(string: uri), !uri.isEmpty, url.scheme != nil else {
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
- if let opacity = props["opacity"] as? NSNumber ?? style["opacity"] as? NSNumber { next.opacity *= CGFloat(truncating: opacity) }
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 tap is the event `navButton`.
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)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "craft-native",
3
- "version": "0.0.163",
3
+ "version": "0.0.164",
4
4
  "type": "module",
5
5
  "description": "Build desktop apps with web languages - TypeScript SDK for Craft",
6
6
  "author": "Chris Breuer <chris@stacksjs.org>",