craft-native 0.0.293 → 0.0.299

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.
@@ -45,6 +45,10 @@ export interface CraftAndroidConfig {
45
45
  renderer?: 'web' | 'native';
46
46
  /** "system" follows the device; light/dark pin the generated host. */
47
47
  appearance?: 'light' | 'dark' | 'system';
48
+ /** Static native tab-bar styling applied before the page's own tabBar.set call. */
49
+ tabBar?: CraftTabBarConfig;
50
+ /** Runs from the generated project root before Gradle compiles the app. */
51
+ postGenerate?: CraftPostGenerateHook;
48
52
  version?: string;
49
53
  versionCode?: number;
50
54
  darkMode?: boolean;
@@ -176,6 +180,19 @@ export interface CraftAndroidConfig {
176
180
  sessions?: boolean;
177
181
  };
178
182
  }
183
+ export interface CraftTabBarConfig {
184
+ selection?: {
185
+ /** The selected tab indicator's corner radius in dp; omitted keeps the default. */
186
+ cornerRadius?: number;
187
+ /** The selected tab indicator fill as #RRGGBB. */
188
+ color?: string;
189
+ };
190
+ }
191
+ /** A declared command Craft runs after the native project has been generated. */
192
+ export interface CraftPostGenerateHook {
193
+ command: string;
194
+ args?: string[];
195
+ }
179
196
  /** The shared health data types (the iOS builder's HealthKit names). */
180
197
  export type HealthConnectType = 'steps' | 'heartRate' | 'activeEnergy' | 'distance' | 'workouts' | 'restingHeartRate' | 'heartRateVariability' | 'bodyMass' | 'sleep' | 'mindful' | 'dietaryEnergy' | 'dietaryProtein' | 'dietaryCarbohydrates' | 'dietaryFat' | 'bloodGlucose' | 'insulinDelivery';
181
198
  /**
@@ -227,6 +244,13 @@ export interface BuildOptions {
227
244
  /** Same meaning as `InitOptions.runtimeDir`; refreshes what init installed. */
228
245
  runtimeDir?: string | null;
229
246
  }
247
+ /**
248
+ * Run the app's declared generator hook without invoking a shell.
249
+ *
250
+ * A hook gets the generated project as its cwd and explicit metadata so a
251
+ * patch can target the right platform and phase without guessing from paths.
252
+ */
253
+ export declare function runPostGenerateHook(hook: CraftPostGenerateHook | undefined, output: string, phase?: 'build'): void;
230
254
  export interface OpenOptions {
231
255
  output: string;
232
256
  }
@@ -507,6 +507,30 @@ function healthConnectPermissions(config) {
507
507
  permissions.add("android.permission.health.READ_HEALTH_DATA_HISTORY");
508
508
  return [...permissions];
509
509
  }
510
+ function runPostGenerateHook(hook, output, phase = "build") {
511
+ if (!hook)
512
+ return;
513
+ if (typeof hook.command !== "string" || !hook.command.trim())
514
+ throw new Error("Android postGenerate.command must be a non-empty executable");
515
+ if (hook.args !== undefined && (!Array.isArray(hook.args) || hook.args.some((argument) => typeof argument !== "string")))
516
+ throw new Error("Android postGenerate.args must be an array of strings");
517
+ const command = [hook.command, ...hook.args ?? []];
518
+ let result;
519
+ try {
520
+ result = Bun.spawnSync(command, {
521
+ cwd: output,
522
+ env: { ...process.env, CRAFT_PROJECT_ROOT: output, CRAFT_PLATFORM: "android", CRAFT_GENERATE_PHASE: phase },
523
+ stdout: "inherit",
524
+ stderr: "pipe"
525
+ });
526
+ } catch (error) {
527
+ throw new Error(`Android postGenerate hook could not start in ${output}: ${hook.command}`, { cause: error });
528
+ }
529
+ if (result.exitCode !== 0) {
530
+ const stderr = result.stderr?.toString().trim();
531
+ throw new Error(`Android postGenerate hook failed in ${output} with exit code ${result.exitCode}${stderr ? `: ${stderr}` : ""}`);
532
+ }
533
+ }
510
534
  var DEFAULT_CONFIG = {
511
535
  renderer: "web",
512
536
  version: "1.0.0",
@@ -634,6 +658,11 @@ function validateAndroidConfig(config) {
634
658
  if (config.appearance !== undefined && !["light", "dark", "system"].includes(config.appearance)) {
635
659
  throw new Error(`Unknown Android appearance: ${config.appearance}. Expected light, dark, or system.`);
636
660
  }
661
+ const selection = config.tabBar?.selection;
662
+ if (selection?.cornerRadius !== undefined && (!Number.isFinite(selection.cornerRadius) || selection.cornerRadius < 0))
663
+ throw new Error("Android tabBar.selection.cornerRadius must be a finite non-negative number");
664
+ if (selection?.color !== undefined && !/^#[0-9a-f]{6}$/i.test(selection.color))
665
+ throw new Error("Android tabBar.selection.color must be a #RRGGBB color");
637
666
  const packageSegments = config.packageName.split(".");
638
667
  if (packageSegments.length < 2 || packageSegments.some((segment) => !/^[A-Za-z_][A-Za-z0-9_]*$/.test(segment) || KOTLIN_KEYWORDS.has(segment))) {
639
668
  throw new Error(`Invalid Android package name: ${config.packageName}`);
@@ -1398,6 +1427,7 @@ async function build(options) {
1398
1427
  console.log(` Synced: ${htmlPath} \u2192 assets/`);
1399
1428
  }
1400
1429
  syncNativeFonts(config.nativeFonts, output);
1430
+ runPostGenerateHook(config.postGenerate, output);
1401
1431
  if (!compile)
1402
1432
  return;
1403
1433
  const gradlewPath = join3(output, "gradlew");
@@ -1486,6 +1516,7 @@ export {
1486
1516
  resolveRuntimeDir,
1487
1517
  run,
1488
1518
  runKotlinFixture,
1519
+ runPostGenerateHook,
1489
1520
  syncAndroidWebAssets,
1490
1521
  syncNativeFonts
1491
1522
  };
@@ -86,6 +86,11 @@ internal data class CraftNativeTab(
86
86
  val selectedSymbol: String? = null, val selectedIconTint: String? = null, val selectedIconPulse: Boolean? = null,
87
87
  )
88
88
 
89
+ internal data class CraftNativeTabBarAppearance(
90
+ val selectionColor: String? = null,
91
+ val selectionCornerRadius: Float? = null,
92
+ )
93
+
89
94
  internal data class CraftTabIconAppearance(val symbol: String, val tint: Int?, val pulse: Boolean)
90
95
 
91
96
  /** The app's paths (`nativeScreens`), its tabs and its site. */
@@ -122,6 +127,16 @@ internal class CraftNativeRoutes(config: JSONObject) {
122
127
  }
123
128
  }
124
129
 
130
+ val tabBarAppearance: CraftNativeTabBarAppearance = config.optJSONObject("tabBar")
131
+ ?.optJSONObject("selection")
132
+ ?.let { selection ->
133
+ CraftNativeTabBarAppearance(
134
+ selection.optString("color").takeIf { it.isNotBlank() },
135
+ (selection.opt("cornerRadius") as? Number)?.toFloat()?.takeIf { it.isFinite() && it >= 0 },
136
+ )
137
+ }
138
+ ?: CraftNativeTabBarAppearance()
139
+
125
140
  /** The app's site (`devServerURL`): its origin answers paths no screen draws. */
126
141
  val site: Uri? = config.optString("devServerURL").takeIf { it.isNotBlank() }?.let(Uri::parse)
127
142
  val siteOrigin: String? = site?.let { uri ->
@@ -755,7 +770,11 @@ internal object CraftNativePrelude {
755
770
  * `craft.navigation.setOptions` (title, largeTitle, hidden, rightButtons,
756
771
  * tabAccessory, tabBarHidden); the tabs come from the app's `tabs`.
757
772
  */
758
- internal class CraftNativeChrome(context: Context, private val tabs: List<CraftNativeTab>) : LinearLayout(context) {
773
+ internal class CraftNativeChrome(
774
+ context: Context,
775
+ private val tabs: List<CraftNativeTab>,
776
+ private val tabBarAppearance: CraftNativeTabBarAppearance = CraftNativeTabBarAppearance(),
777
+ ) : LinearLayout(context) {
759
778
  val content = FrameLayout(context)
760
779
  private val bar = LinearLayout(context)
761
780
  private val backButton = ImageView(context)
@@ -1099,6 +1118,7 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
1099
1118
  accessorySpec?.let { accessoryIcon.setImageDrawable(CraftNativeIcons.drawable(context, null, it.optString("symbol"), text)) }
1100
1119
  tabViews.forEachIndexed { index, (icon, label) ->
1101
1120
  tabBar.getChildAt(index).isSelected = index == selectedTab
1121
+ tabBar.getChildAt(index).background = tabCellBackground(index, dark)
1102
1122
  val color = if (index == selectedTab) accent else muted
1103
1123
  val id = tabs[index].id
1104
1124
  val number = tabIconText[id]
@@ -1188,6 +1208,17 @@ internal class CraftNativeChrome(context: Context, private val tabs: List<CraftN
1188
1208
 
1189
1209
  private fun accentColor(dark: Boolean): Int = if (dark) Color.parseColor("#0A84FF") else Color.parseColor("#007AFF")
1190
1210
 
1211
+ private fun tabCellBackground(index: Int, dark: Boolean): android.graphics.drawable.Drawable? {
1212
+ if (index != selectedTab || (tabBarAppearance.selectionColor == null && tabBarAppearance.selectionCornerRadius == null)) return rippleBackground()
1213
+ val color = tabBarAppearance.selectionColor?.let { runCatching { Color.parseColor(it) }.getOrNull() }
1214
+ ?: if (dark) Color.argb(42, 255, 255, 255) else Color.argb(23, 0, 0, 0)
1215
+ val radius = tabBarAppearance.selectionCornerRadius?.let { (it * density).toInt().toFloat() } ?: dp(32).toFloat()
1216
+ return GradientDrawable().apply {
1217
+ setColor(color)
1218
+ cornerRadius = radius
1219
+ }
1220
+ }
1221
+
1191
1222
  private fun rippleBackground(): android.graphics.drawable.Drawable? {
1192
1223
  val value = TypedValue()
1193
1224
  return if (context.theme.resolveAttribute(android.R.attr.selectableItemBackgroundBorderless, value, true)) {
@@ -2736,6 +2736,51 @@ private class CraftNativeIconView(context: Context) : TextView(context) {
2736
2736
  }
2737
2737
  }
2738
2738
 
2739
+ /** A platform EditText with the native end clear affordance and TalkBack action. */
2740
+ private class CraftNativeTextField(context: Context) : EditText(context) {
2741
+ companion object { val CLEAR_ACTION_ID = View.generateViewId() }
2742
+ var clearButtonMode = "never"
2743
+ var onClearRequest: (() -> Unit)? = null
2744
+ private var clearPressed = false
2745
+ private fun showsClear() = isEnabled && keyListener != null && text?.isNotEmpty() == true && when (clearButtonMode) {
2746
+ "always" -> true
2747
+ "while-editing" -> isFocused
2748
+ "unless-editing" -> !isFocused
2749
+ else -> false
2750
+ }
2751
+ fun refreshClearButton() {
2752
+ val icon = if (showsClear()) context.getDrawable(android.R.drawable.ic_menu_close_clear_cancel)?.mutate() else null
2753
+ icon?.setTint(currentHintTextColor)
2754
+ setCompoundDrawablesRelativeWithIntrinsicBounds(null, null, icon, null)
2755
+ }
2756
+ override fun onTextChanged(text: CharSequence?, start: Int, lengthBefore: Int, lengthAfter: Int) {
2757
+ super.onTextChanged(text, start, lengthBefore, lengthAfter)
2758
+ refreshClearButton()
2759
+ }
2760
+ override fun onFocusChanged(focused: Boolean, direction: Int, previouslyFocusedRect: android.graphics.Rect?) {
2761
+ super.onFocusChanged(focused, direction, previouslyFocusedRect)
2762
+ refreshClearButton()
2763
+ }
2764
+ override fun onTouchEvent(event: MotionEvent): Boolean {
2765
+ val inClear = showsClear() && if (layoutDirection == View.LAYOUT_DIRECTION_RTL) event.x <= compoundPaddingLeft else event.x >= width - compoundPaddingRight
2766
+ if (event.actionMasked == MotionEvent.ACTION_DOWN && inClear) { clearPressed = true; return true }
2767
+ if (clearPressed) {
2768
+ if (event.actionMasked == MotionEvent.ACTION_UP) { clearPressed = false; if (inClear) onClearRequest?.invoke(); return true }
2769
+ if (event.actionMasked == MotionEvent.ACTION_CANCEL) clearPressed = false
2770
+ return true
2771
+ }
2772
+ return super.onTouchEvent(event)
2773
+ }
2774
+ override fun onInitializeAccessibilityNodeInfo(info: android.view.accessibility.AccessibilityNodeInfo) {
2775
+ super.onInitializeAccessibilityNodeInfo(info)
2776
+ if (showsClear()) info.addAction(android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction(CLEAR_ACTION_ID, "Clear text"))
2777
+ }
2778
+ override fun performAccessibilityAction(action: Int, arguments: Bundle?): Boolean {
2779
+ if (action == CLEAR_ACTION_ID && showsClear()) { onClearRequest?.invoke(); return true }
2780
+ return super.performAccessibilityAction(action, arguments)
2781
+ }
2782
+ }
2783
+
2739
2784
  private class CraftNativeModalView(context: Context) : FrameLayout(context) {
2740
2785
  val content = CraftNativeFlexLayout(context)
2741
2786
  private val blocker = View(context)
@@ -2745,6 +2790,16 @@ private class CraftNativeModalView(context: Context) : FrameLayout(context) {
2745
2790
  var onRequestClose: ((String) -> Unit)? = null
2746
2791
  var viewportStyle = JSONObject()
2747
2792
  var declarationPath = ""
2793
+ var sheetFraction: Float? = null
2794
+
2795
+ override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
2796
+ val fraction = sheetFraction
2797
+ val params = content.layoutParams as LayoutParams
2798
+ params.height = if (fraction == null) LayoutParams.MATCH_PARENT else (View.MeasureSpec.getSize(heightMeasureSpec) * fraction).toInt()
2799
+ params.gravity = Gravity.BOTTOM
2800
+ content.layoutParams = params
2801
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec)
2802
+ }
2748
2803
 
2749
2804
  var transparent: Boolean = false
2750
2805
  set(value) {
@@ -3095,18 +3150,28 @@ internal class CraftNativeInputEcho {
3095
3150
  private var rendered: String? = null
3096
3151
  private var initialized = false
3097
3152
 
3098
- fun record(value: String) {
3153
+ private var eventCount = 0
3154
+
3155
+ fun record(value: String): Int {
3099
3156
  pending.addLast(value)
3100
3157
  while (pending.size > 64) pending.removeFirst()
3158
+ return ++eventCount
3101
3159
  }
3102
3160
 
3103
- fun accept(value: String?, nativeValue: String): Boolean {
3161
+ fun accept(value: String?, nativeValue: String, acknowledged: Int? = null): Boolean {
3104
3162
  if (value == null) {
3105
3163
  initialized = false
3106
3164
  rendered = null
3107
3165
  pending.clear()
3108
3166
  return false
3109
3167
  }
3168
+ if (acknowledged != null && acknowledged >= 0) {
3169
+ if (acknowledged < eventCount) return false
3170
+ pending.clear()
3171
+ initialized = true
3172
+ rendered = value
3173
+ return nativeValue != value
3174
+ }
3110
3175
  // A retained field can render the same prop more than once, after
3111
3176
  // its echo was consumed. That repeated prop must not overwrite a
3112
3177
  // keystroke that has not made the native/JS round trip yet.
@@ -4198,7 +4263,7 @@ class MainActivity : AppCompatActivity() {
4198
4263
  }
4199
4264
 
4200
4265
  private fun installChrome() {
4201
- val bars = CraftNativeChrome(this, routes.tabs)
4266
+ val bars = CraftNativeChrome(this, routes.tabs, routes.tabBarAppearance)
4202
4267
  bars.layoutParams = FrameLayout.LayoutParams(-1, -1)
4203
4268
  bars.onBack = { onBackPressedDispatcherCompat() }
4204
4269
  bars.onAccessory = { path -> navigateOpen(path) }
@@ -4866,6 +4931,7 @@ class MainActivity : AppCompatActivity() {
4866
4931
  put("viewScroll")
4867
4932
  put("viewReparent")
4868
4933
  put("viewNonModalPresentation")
4934
+ put("nativePlatformControls")
4869
4935
  }
4870
4936
 
4871
4937
  private fun appVersion(): Pair<String, String> = runCatching {
@@ -5360,7 +5426,7 @@ class MainActivity : AppCompatActivity() {
5360
5426
  previousId == null || previousId == protocolId || !screen.mutations.containsNode(previousId)
5361
5427
  }
5362
5428
  val previous = protocolId?.let { previousControls["id:$it"] } ?: keyedPrevious
5363
- val reusableInput = previous as? EditText
5429
+ val reusableInput = previous as? CraftNativeTextField
5364
5430
  val control = when (type) {
5365
5431
  "Text" -> ((previous as? TextView)?.takeUnless { it is Button || it is EditText } ?: TextView(this)).apply {
5366
5432
  includeFontPadding = false
@@ -5465,7 +5531,7 @@ class MainActivity : AppCompatActivity() {
5465
5531
  }
5466
5532
  }
5467
5533
  }
5468
- "TextInput" -> (reusableInput ?: EditText(this)).apply {
5534
+ "TextInput" -> (reusableInput ?: CraftNativeTextField(this)).apply {
5469
5535
  // Setting filters, the input type or single-line mode makes
5470
5536
  // TextView set its text again, which puts the caret back at
5471
5537
  // the start: typing then came out backwards. Each is set only
@@ -5531,9 +5597,21 @@ class MainActivity : AppCompatActivity() {
5531
5597
  // them after keyed reconciliation.
5532
5598
  val autofillHint = autofillHintFor(props)
5533
5599
  if (autofillHint == null) setAutofillHints(*emptyArray()) else setAutofillHints(autofillHint)
5600
+ clearButtonMode = craftNativeOptionalString(props.opt("clearButtonMode")) ?: "never"
5601
+ onClearRequest = {
5602
+ if (isEnabled && keyListener != null) {
5603
+ screen.suppressedTextChanges.add(this)
5604
+ setText("")
5605
+ val eventCount = screen.inputEchoes.getOrPut(this) { CraftNativeInputEcho() }.record("")
5606
+ screen.handlers[this]?.let { current ->
5607
+ send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject().put("text", "").put("clear", true).put("eventCount", eventCount)))
5608
+ }
5609
+ }
5610
+ }
5611
+ refreshClearButton()
5534
5612
  val controlledValue = props.opt("value") as? String
5535
5613
  val echoes = screen.inputEchoes.getOrPut(this) { CraftNativeInputEcho() }
5536
- if (echoes.accept(controlledValue, text.toString()) && controlledValue != null) {
5614
+ if (echoes.accept(controlledValue, text.toString(), craftNativeInt(props.opt("mostRecentEventCount"), -1).takeIf { it >= 0 }) && controlledValue != null) {
5537
5615
  screen.suppressedTextChanges.add(this)
5538
5616
  setTextPreservingSelection(this, controlledValue)
5539
5617
  }
@@ -5596,10 +5674,10 @@ class MainActivity : AppCompatActivity() {
5596
5674
  (screen.inputIdentities[this@apply] ?: identity).let { screen.drafts[it] = updated }
5597
5675
  if (screen.suppressedTextChanges.remove(this@apply)) return
5598
5676
  val current = screen.handlers[this@apply] ?: return
5599
- screen.inputEchoes.getOrPut(this@apply) { CraftNativeInputEcho() }.record(updated)
5677
+ val eventCount = screen.inputEchoes.getOrPut(this@apply) { CraftNativeInputEcho() }.record(updated)
5600
5678
  send(screen, "EVENT", JSONObject()
5601
5679
  .put("handlerName", current)
5602
- .put("nativeEvent", JSONObject().put("text", updated)))
5680
+ .put("nativeEvent", JSONObject().put("text", updated).put("eventCount", eventCount)))
5603
5681
  }
5604
5682
  override fun afterTextChanged(s: Editable?) {}
5605
5683
  }
@@ -5607,6 +5685,57 @@ class MainActivity : AppCompatActivity() {
5607
5685
  addTextChangedListener(watcher)
5608
5686
  }
5609
5687
  }
5688
+ "SegmentedControl" -> (previous as? android.widget.RadioGroup ?: android.widget.RadioGroup(this)).apply {
5689
+ orientation = android.widget.LinearLayout.HORIZONTAL
5690
+ // Reconciliation is silent. Only the platform's user selection
5691
+ // listener below emits a value, with the stable option value.
5692
+ setOnCheckedChangeListener(null)
5693
+ val items = props.optJSONArray("items") ?: JSONArray()
5694
+ val valuesChanged = childCount != items.length() || (0 until items.length()).any { index ->
5695
+ getChildAt(index)?.tag != craftNativePickerValue(items.optJSONObject(index)?.opt("value")).orEmpty()
5696
+ }
5697
+ if (valuesChanged) removeAllViews()
5698
+ val requested = props.opt("value") as? String ?: craftNativePickerValue(props.opt("value")) ?: props.opt("selectedValue") as? String ?: craftNativePickerValue(props.opt("selectedValue"))
5699
+ val disabled = craftNativeBoolean(props.opt("disabled"))
5700
+ val foreground = color(craftNativeOptionalString(style.opt("color")).orEmpty(), Color.BLACK)
5701
+ val values = mutableMapOf<Int, Pair<String, Int>>()
5702
+ for (index in 0 until items.length()) {
5703
+ val item = items.optJSONObject(index) ?: continue
5704
+ val value = craftNativePickerValue(item.opt("value")) ?: ""
5705
+ val segment = (getChildAt(index) as? android.widget.RadioButton ?: android.widget.RadioButton(this@MainActivity)).apply {
5706
+ if (id == View.NO_ID) id = View.generateViewId()
5707
+ tag = value
5708
+ text = craftNativeOptionalString(item.opt("label")) ?: value
5709
+ contentDescription = craftNativeOptionalString(item.opt("spoken")) ?: text
5710
+ isEnabled = !disabled && !craftNativeBoolean(item.opt("disabled"))
5711
+ buttonDrawable = null
5712
+ gravity = Gravity.CENTER
5713
+ setTextColor(foreground)
5714
+ textSize = 13f
5715
+ minHeight = (32 * resources.displayMetrics.density).toInt()
5716
+ setPadding((8 * resources.displayMetrics.density).toInt(), 0, (8 * resources.displayMetrics.density).toInt(), 0)
5717
+ val selected = GradientDrawable().apply {
5718
+ setColor(color(craftNativeOptionalString(props.opt("tintColor")).orEmpty(), 0x33767680))
5719
+ cornerRadius = 7 * resources.displayMetrics.density
5720
+ }
5721
+ background = android.graphics.drawable.StateListDrawable().apply {
5722
+ addState(intArrayOf(android.R.attr.state_checked), selected)
5723
+ addState(intArrayOf(), ColorDrawable(Color.TRANSPARENT))
5724
+ }
5725
+ isChecked = value == requested
5726
+ }
5727
+ values[segment.id] = value to index
5728
+ if (segment.parent == null) addView(segment, android.widget.RadioGroup.LayoutParams(0, android.view.ViewGroup.LayoutParams.WRAP_CONTENT, 1f))
5729
+ }
5730
+ isEnabled = !disabled
5731
+ val handler = craftNativeEventName(events, "onValueChange", "onChange")
5732
+ if (handler.isNotBlank()) screen.handlers[this] = handler else screen.handlers.remove(this)
5733
+ setOnCheckedChangeListener { _, checkedId ->
5734
+ val current = screen.handlers[this] ?: return@setOnCheckedChangeListener
5735
+ val (value, index) = values[checkedId] ?: return@setOnCheckedChangeListener
5736
+ send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject().put("value", value).put("index", index)))
5737
+ }
5738
+ }
5610
5739
  "Picker" -> (previous as? Spinner ?: Spinner(this)).apply {
5611
5740
  val itemOptions = props.optJSONArray("items")?.let { items ->
5612
5741
  (0 until items.length()).mapNotNull { index ->
@@ -5676,6 +5805,14 @@ class MainActivity : AppCompatActivity() {
5676
5805
  // the screen root. Keep the authored layout style with that
5677
5806
  // promoted child so non-modal absolute coordinates remain in
5678
5807
  // the screen viewport rather than the old clipped parent.
5808
+ val sheetStyle = craftNativeOptionalString(props.opt("presentationStyle"))
5809
+ val detents = props.optJSONArray("sheetDetents")
5810
+ val firstDetent = detents?.optString(0)?.takeIf { it == "medium" || it == "large" } ?: "medium"
5811
+ val selectedDetent = craftNativeOptionalString(props.opt("selectedDetent")) ?: firstDetent
5812
+ val fraction = if (blocksBackground && sheetStyle in setOf("pageSheet", "formSheet")) {
5813
+ if (selectedDetent == "large") 0.92f else 0.5f
5814
+ } else null
5815
+ if (sheetFraction != fraction) { sheetFraction = fraction; requestLayout() }
5679
5816
  viewportStyle = style
5680
5817
  declarationPath = path
5681
5818
  screen.view.setChildStyle(this, style, resources.displayMetrics.density)
@@ -5767,7 +5904,10 @@ class MainActivity : AppCompatActivity() {
5767
5904
  intArrayOf(trueColor ?: resolvedFalse, resolvedFalse),
5768
5905
  )
5769
5906
  }
5770
- } ?: run { trackTintList = null }
5907
+ } ?: run {
5908
+ val tint = craftNativeOptionalString(style.opt("color"))?.let { runCatching { craftNativeColor(it) }.getOrNull() }
5909
+ trackTintList = tint?.let { ColorStateList(arrayOf(intArrayOf(android.R.attr.state_checked), intArrayOf()), intArrayOf(it, Color.GRAY)) }
5910
+ }
5771
5911
  craftNativeOptionalString(props.opt("thumbColor"))
5772
5912
  ?.let { runCatching { craftNativeColor(it) }.getOrNull() }
5773
5913
  ?.let { thumbTintList = ColorStateList.valueOf(it) }
@@ -5853,7 +5993,7 @@ class MainActivity : AppCompatActivity() {
5853
5993
  val animating = craftNativeBoolean(props.opt("animating"), true)
5854
5994
  isIndeterminate = animating
5855
5995
  visibility = if (!animating && craftNativeBoolean(props.opt("hidesWhenStopped"), true)) View.GONE else View.VISIBLE
5856
- indeterminateTintList = craftNativeOptionalString(props.opt("color"))
5996
+ indeterminateTintList = (craftNativeOptionalString(props.opt("color")) ?: craftNativeOptionalString(style.opt("color")))
5857
5997
  ?.let { runCatching { craftNativeColor(it) }.getOrNull() }
5858
5998
  ?.let { ColorStateList.valueOf(it) }
5859
5999
  }
@@ -6085,7 +6225,7 @@ class MainActivity : AppCompatActivity() {
6085
6225
  val underlayHideHandler = if (type == "TouchableHighlight") craftNativeEventName(events, "onHideUnderlay").takeIf { it.isNotBlank() } else null
6086
6226
  if (underlayShowHandler != null) screen.pressUnderlayShowHandlers[control] = underlayShowHandler else screen.pressUnderlayShowHandlers.remove(control)
6087
6227
  if (underlayHideHandler != null) screen.pressUnderlayHideHandlers[control] = underlayHideHandler else screen.pressUnderlayHideHandlers.remove(control)
6088
- if (type != "Button" && type != "Link" && type != "Checkbox" && type != "TextInput" && type != "Picker" && type != "DatePicker" && type != "Modal" && type != "Switch" && type != "Slider" && type != "ActivityIndicator" && type != "KeyboardAvoidingView" && type != "StatusBar" && type != "RefreshControl" && type != "Map") {
6228
+ if (type != "Button" && type != "Link" && type != "Checkbox" && type != "TextInput" && type != "Picker" && type != "SegmentedControl" && type != "DatePicker" && type != "Modal" && type != "Switch" && type != "Slider" && type != "ActivityIndicator" && type != "KeyboardAvoidingView" && type != "StatusBar" && type != "RefreshControl" && type != "Map") {
6089
6229
  val handler = craftNativeEventName(events, "onPress", "onClick")
6090
6230
  val disabled = craftNativeBoolean(props.opt("disabled"))
6091
6231
  val target = listOf(props.opt("href"), props.opt("to"))
@@ -6250,6 +6390,7 @@ class MainActivity : AppCompatActivity() {
6250
6390
  "link" -> "Link"
6251
6391
  "checkbox" -> "Checkbox"
6252
6392
  "textinput", "input" -> "TextInput"
6393
+ "segmentedcontrol", "segmented-control" -> "SegmentedControl"
6253
6394
  "picker", "select" -> "Picker"
6254
6395
  "datepicker", "date-picker", "date" -> "DatePicker"
6255
6396
  "modal" -> "Modal"
@@ -7079,7 +7220,7 @@ class MainActivity : AppCompatActivity() {
7079
7220
  }
7080
7221
  val state = props.optJSONObject("accessibilityState")
7081
7222
  val disabled = craftNativeBoolean(props.opt("disabled")) || craftNativeBoolean(state?.opt("disabled"))
7082
- if (type !in setOf("Button", "Link", "Checkbox", "TextInput", "Picker", "DatePicker", "Modal", "Switch", "Slider", "ActivityIndicator")) {
7223
+ if (type !in setOf("Button", "Link", "Checkbox", "TextInput", "Picker", "SegmentedControl", "DatePicker", "Modal", "Switch", "Slider", "ActivityIndicator")) {
7083
7224
  view.isEnabled = !disabled
7084
7225
  } else if (disabled) {
7085
7226
  view.isEnabled = false
package/dist/api/app.d.ts CHANGED
@@ -361,11 +361,11 @@ declare class AppManager {
361
361
  */
362
362
  isShortcutRegistered(accelerator: string): Promise<boolean>;
363
363
  /**
364
- * Set app appearance (macOS)
364
+ * Set the app appearance on native hosts.
365
365
  */
366
366
  setAppearance(appearance: 'light' | 'dark' | 'system'): Promise<void>;
367
367
  /**
368
- * Get current appearance
368
+ * Get the current effective Light/Dark appearance.
369
369
  */
370
370
  getAppearance(): Promise<'light' | 'dark'>;
371
371
  /**
package/dist/cli.js CHANGED
@@ -2945,7 +2945,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
2945
2945
  `);
2946
2946
  }
2947
2947
  // package.json
2948
- var version = "0.0.293";
2948
+ var version = "0.0.299";
2949
2949
 
2950
2950
  // bin/cli.ts
2951
2951
  var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
@@ -721,6 +721,9 @@ export interface PressableProps extends ViewProps {
721
721
  * TextInput component props.
722
722
  */
723
723
  export interface TextInputProps extends BaseProps {
724
+ /** Runtime acknowledgement of the latest native edit; stale controlled echoes are ignored. */
725
+ mostRecentEventCount?: number;
726
+ clearButtonMode?: 'never' | 'while-editing' | 'unless-editing' | 'always';
724
727
  /** Style object or array */
725
728
  style?: StyleValue<TextStyle>;
726
729
  /** Current value */
@@ -831,6 +834,20 @@ export interface PickerProps extends BaseProps {
831
834
  onChange?: (value: string | number, index: number) => void;
832
835
  disabled?: boolean;
833
836
  }
837
+ /** Platform-owned segmented selection (UISegmentedControl / Android RadioGroup). */
838
+ export interface SegmentedControlProps extends BaseProps {
839
+ items: Array<PickerOption & {
840
+ disabled?: boolean;
841
+ spoken?: string;
842
+ }>;
843
+ value?: string | number;
844
+ disabled?: boolean;
845
+ tintColor?: string;
846
+ onValueChange?: (event: {
847
+ value: string;
848
+ index: number;
849
+ }) => void;
850
+ }
834
851
  export interface DatePickerChange {
835
852
  value: string;
836
853
  iso: string;
@@ -890,6 +907,10 @@ export interface ModalProps extends BaseProps {
890
907
  nonModal?: boolean;
891
908
  /** When non-modal, consume an outside tap and call onRequestClose. */
892
909
  dismissOnOutsideTap?: boolean;
910
+ presentationStyle?: 'fullScreen' | 'pageSheet' | 'formSheet' | 'overFullScreen';
911
+ sheetDetents?: Array<'medium' | 'large'>;
912
+ selectedDetent?: 'medium' | 'large';
913
+ grabberVisible?: boolean;
893
914
  animationType?: 'none' | 'fade' | 'slide';
894
915
  onShow?: () => void;
895
916
  onDismiss?: () => void;
package/dist/index.cjs CHANGED
@@ -374,7 +374,7 @@ function craftBinaryNotFoundMessage(triedPath) {
374
374
  `);
375
375
  }
376
376
  // package.json
377
- var version = "0.0.293";
377
+ var version = "0.0.299";
378
378
 
379
379
  // src/sdk-version.ts
380
380
  var SDK_VERSION = version;
package/dist/index.js CHANGED
@@ -117,7 +117,7 @@ function craftBinaryNotFoundMessage(triedPath) {
117
117
  `);
118
118
  }
119
119
  // package.json
120
- var version = "0.0.293";
120
+ var version = "0.0.299";
121
121
 
122
122
  // src/sdk-version.ts
123
123
  var SDK_VERSION = version;
@@ -9,6 +9,19 @@ export declare function syncNativeFonts(fonts: CraftConfig['nativeFonts'], outpu
9
9
  /** This package's version, which the app names in its user agent (`Craft/<version>`). */
10
10
  export declare function resolveCraftVersion(startDir?: string): string;
11
11
  export type CraftAppearance = 'light' | 'dark' | 'system';
12
+ /** A declared command Craft runs after the native project has been generated. */
13
+ export interface CraftPostGenerateHook {
14
+ command: string;
15
+ args?: string[];
16
+ }
17
+ export interface CraftTabBarConfig {
18
+ selection?: {
19
+ /** The selected tab indicator's corner radius in points; omitted keeps a capsule. */
20
+ cornerRadius?: number;
21
+ /** The selected tab indicator fill as #RRGGBB. */
22
+ color?: string;
23
+ };
24
+ }
12
25
  export interface CraftConfig {
13
26
  appShortcuts?: CraftAppShortcut[];
14
27
  /** Bundled font files keyed by their PostScript font name, used as native fontFamily. */
@@ -29,6 +42,10 @@ export interface CraftConfig {
29
42
  * and `dark` pin one. Unset keeps the older `darkMode` switch.
30
43
  */
31
44
  appearance?: CraftAppearance;
45
+ /** Static native tab-bar styling applied before the page's own tabBar.set call. */
46
+ tabBar?: CraftTabBarConfig;
47
+ /** Runs from the generated project root after `craft ios build` writes it. */
48
+ postGenerate?: CraftPostGenerateHook;
32
49
  backgroundColor?: string;
33
50
  /** The launch and webview background while the phone is in Dark Mode. */
34
51
  backgroundColorDark?: string;
@@ -409,6 +426,13 @@ export interface BuildOptions {
409
426
  */
410
427
  runtimeDir?: string | null;
411
428
  }
429
+ /**
430
+ * Run the app's declared generator hook without invoking a shell.
431
+ *
432
+ * A hook gets the generated project as its cwd and explicit metadata so a
433
+ * patch can target the right platform and phase without guessing from paths.
434
+ */
435
+ export declare function runPostGenerateHook(hook: CraftPostGenerateHook | undefined, output: string, phase?: 'build'): void;
412
436
  export interface OpenOptions {
413
437
  output: string;
414
438
  }
@@ -512,6 +512,30 @@ function installNativeBundle(source, output) {
512
512
  function hasInstalledNativeBundle(output) {
513
513
  return existsSync(join3(output, "dist", "native-screen.js")) || existsSync(join3(output, "dist", "native-screens"));
514
514
  }
515
+ function runPostGenerateHook(hook, output, phase = "build") {
516
+ if (!hook)
517
+ return;
518
+ if (typeof hook.command !== "string" || !hook.command.trim())
519
+ throw new Error("iOS postGenerate.command must be a non-empty executable");
520
+ if (hook.args !== undefined && (!Array.isArray(hook.args) || hook.args.some((argument) => typeof argument !== "string")))
521
+ throw new Error("iOS postGenerate.args must be an array of strings");
522
+ const command = [hook.command, ...hook.args ?? []];
523
+ let result;
524
+ try {
525
+ result = Bun.spawnSync(command, {
526
+ cwd: output,
527
+ env: { ...process.env, CRAFT_PROJECT_ROOT: output, CRAFT_PLATFORM: "ios", CRAFT_GENERATE_PHASE: phase },
528
+ stdout: "inherit",
529
+ stderr: "pipe"
530
+ });
531
+ } catch (error) {
532
+ throw new Error(`iOS postGenerate hook could not start in ${output}: ${hook.command}`, { cause: error });
533
+ }
534
+ if (result.exitCode !== 0) {
535
+ const stderr = result.stderr?.toString().trim();
536
+ throw new Error(`iOS postGenerate hook failed in ${output} with exit code ${result.exitCode}${stderr ? `: ${stderr}` : ""}`);
537
+ }
538
+ }
515
539
  var DEFAULT_CONFIG = {
516
540
  renderer: "web",
517
541
  version: "1.0.0",
@@ -1230,6 +1254,11 @@ async function init(options) {
1230
1254
  if (config.renderer !== "web" && config.renderer !== "native") {
1231
1255
  throw new Error(`Unknown iOS renderer: ${config.renderer}. Expected web or native.`);
1232
1256
  }
1257
+ const selection = config.tabBar?.selection;
1258
+ if (selection?.cornerRadius !== undefined && (!Number.isFinite(selection.cornerRadius) || selection.cornerRadius < 0))
1259
+ throw new Error("iOS tabBar.selection.cornerRadius must be a finite non-negative number");
1260
+ if (selection?.color !== undefined && !/^#[0-9a-f]{6}$/i.test(selection.color))
1261
+ throw new Error("iOS tabBar.selection.color must be a #RRGGBB color");
1233
1262
  if (config.enableBackgroundLocation)
1234
1263
  config.enableGeolocation = true;
1235
1264
  const problems = hybridConfigProblems(config);
@@ -1425,6 +1454,7 @@ async function build(options) {
1425
1454
  cause: error
1426
1455
  });
1427
1456
  }
1457
+ runPostGenerateHook(config.postGenerate, output);
1428
1458
  console.log("");
1429
1459
  }
1430
1460
  async function open(options) {
@@ -1590,6 +1620,7 @@ export {
1590
1620
  resolveRuntimeDir,
1591
1621
  restoreShell,
1592
1622
  run,
1623
+ runPostGenerateHook,
1593
1624
  serializeSimulatorSteps,
1594
1625
  showSimulator,
1595
1626
  syncNativeFonts,