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.
- package/dist/android/src/index.d.ts +24 -0
- package/dist/android/src/index.js +31 -0
- package/dist/android/templates/CraftNativeShell.kt.template +32 -1
- package/dist/android/templates/MainActivityNative.kt.template +153 -12
- package/dist/api/app.d.ts +2 -2
- package/dist/cli.js +1 -1
- package/dist/components/index.d.ts +21 -0
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/src/index.d.ts +24 -0
- package/dist/ios/src/index.js +31 -0
- package/dist/ios/templates/CraftApp.swift +153 -3
- package/dist/ios/templates/CraftHybrid.swift +1 -1
- package/dist/ios/templates/CraftNativeActions.swift +1 -1
- package/dist/ios/templates/CraftNativeScreen.swift +294 -23
- package/dist/ios/templates/CraftWatchApp.swift.template +4 -0
- package/dist/types.d.ts +15 -0
- package/package.json +1 -1
|
@@ -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(
|
|
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
|
-
|
|
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?
|
|
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 ?:
|
|
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 {
|
|
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
|
|
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.
|
|
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
package/dist/index.js
CHANGED
package/dist/ios/src/index.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/ios/src/index.js
CHANGED
|
@@ -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,
|