craft-native 0.0.122 → 0.0.124
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -0
- package/dist/android/src/index.d.ts +3 -0
- package/dist/android/src/index.js +17 -0
- package/dist/android/templates/AndroidManifest.xml.template +1 -1
- package/dist/android/templates/CraftNativeFlatList.kt.template +113 -6
- package/dist/android/templates/MainActivity.kt.template +17 -0
- package/dist/android/templates/MainActivityNative.kt.template +826 -68
- package/dist/api/mobile.d.ts +251 -0
- package/dist/cli.js +1 -1
- package/dist/index.cjs +22 -1
- package/dist/index.js +22 -1
- package/dist/ios/src/index.d.ts +90 -2
- package/dist/ios/src/index.js +177 -44
- package/dist/ios/templates/CraftApp.swift +1341 -51
- package/dist/ios/templates/CraftNativeActions.swift +97 -9
- package/dist/ios/templates/CraftNativeFlatList.swift +92 -12
- package/dist/ios/templates/CraftNativeScreen.swift +876 -64
- package/dist/ios/templates/CraftNativeUI.swift +562 -0
- package/dist/ios/templates/Info.plist.template +3 -3
- package/dist/mobile.js +187 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -470,6 +470,9 @@ should use the canonical spellings. A capability disabled by app configuration
|
|
|
470
470
|
is distinct from a user denying permission, and `BUSY` is distinct from a failed
|
|
471
471
|
native call.
|
|
472
472
|
|
|
473
|
+
On iOS, `window.addEventListener('craftTabBarLayout', ...)` reports the native
|
|
474
|
+
tab bar's current height as `{ detail: { height } }` in CSS pixels.
|
|
475
|
+
|
|
473
476
|
## Requirements
|
|
474
477
|
|
|
475
478
|
- Bun >= 1.0.0 (for development)
|
|
@@ -9,10 +9,13 @@ export interface CraftAndroidConfig {
|
|
|
9
9
|
appName: string;
|
|
10
10
|
packageName: string;
|
|
11
11
|
renderer?: 'web' | 'native';
|
|
12
|
+
/** "system" follows the device; light/dark pin the generated host. */
|
|
13
|
+
appearance?: 'light' | 'dark' | 'system';
|
|
12
14
|
version?: string;
|
|
13
15
|
versionCode?: number;
|
|
14
16
|
darkMode?: boolean;
|
|
15
17
|
backgroundColor?: string;
|
|
18
|
+
backgroundColorDark?: string;
|
|
16
19
|
enableSpeechRecognition?: boolean;
|
|
17
20
|
enableHaptics?: boolean;
|
|
18
21
|
enableShare?: boolean;
|
|
@@ -216,6 +216,9 @@ function validateAndroidConfig(config) {
|
|
|
216
216
|
if (config.renderer !== undefined && config.renderer !== "web" && config.renderer !== "native") {
|
|
217
217
|
throw new Error(`Unknown Android renderer: ${config.renderer}. Expected web or native.`);
|
|
218
218
|
}
|
|
219
|
+
if (config.appearance !== undefined && !["light", "dark", "system"].includes(config.appearance)) {
|
|
220
|
+
throw new Error(`Unknown Android appearance: ${config.appearance}. Expected light, dark, or system.`);
|
|
221
|
+
}
|
|
219
222
|
const packageSegments = config.packageName.split(".");
|
|
220
223
|
if (packageSegments.length < 2 || packageSegments.some((segment) => !/^[A-Za-z_][A-Za-z0-9_]*$/.test(segment) || KOTLIN_KEYWORDS.has(segment))) {
|
|
221
224
|
throw new Error(`Invalid Android package name: ${config.packageName}`);
|
|
@@ -223,6 +226,9 @@ function validateAndroidConfig(config) {
|
|
|
223
226
|
if (!/^#(?:[\dA-F]{3,4}|[\dA-F]{6}|[\dA-F]{8})$/i.test(config.backgroundColor ?? "")) {
|
|
224
227
|
throw new Error(`Invalid Android background color: ${config.backgroundColor}`);
|
|
225
228
|
}
|
|
229
|
+
if (config.backgroundColorDark !== undefined && !config.backgroundColorDark.match(/^#[\dA-F]{3,4}$|^#[\dA-F]{6}$|^#[\dA-F]{8}$/i)) {
|
|
230
|
+
throw new Error(`Invalid Android dark background color: ${config.backgroundColorDark}`);
|
|
231
|
+
}
|
|
226
232
|
for (const [name, value] of [
|
|
227
233
|
["versionCode", config.versionCode],
|
|
228
234
|
["minSdk", config.minSdk],
|
|
@@ -395,6 +401,7 @@ async function init(options) {
|
|
|
395
401
|
join(output, "app/src/main/java", packagePath),
|
|
396
402
|
join(output, "app/src/main/res/layout"),
|
|
397
403
|
join(output, "app/src/main/res/values"),
|
|
404
|
+
join(output, "app/src/main/res/values-night"),
|
|
398
405
|
join(output, "app/src/main/res/drawable"),
|
|
399
406
|
join(output, "app/src/main/assets"),
|
|
400
407
|
join(output, "gradle/wrapper")
|
|
@@ -508,6 +515,16 @@ zipStorePath=wrapper/dists
|
|
|
508
515
|
</resources>
|
|
509
516
|
`;
|
|
510
517
|
writeFileSync(join(output, "app/src/main/res/values/colors.xml"), colorsXml);
|
|
518
|
+
const darkColorsPath = join(output, "app/src/main/res/values-night/colors.xml");
|
|
519
|
+
if (config.backgroundColorDark) {
|
|
520
|
+
writeFileSync(darkColorsPath, `<?xml version="1.0" encoding="utf-8"?>
|
|
521
|
+
<resources>
|
|
522
|
+
<color name="background">${config.backgroundColorDark}</color>
|
|
523
|
+
</resources>
|
|
524
|
+
`);
|
|
525
|
+
} else {
|
|
526
|
+
rmSync(darkColorsPath, { force: true });
|
|
527
|
+
}
|
|
511
528
|
const appIconXml = `<?xml version="1.0" encoding="utf-8"?>
|
|
512
529
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
513
530
|
android:width="108dp"
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
<activity
|
|
34
34
|
android:name=".MainActivity"
|
|
35
35
|
android:exported="true"
|
|
36
|
-
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
|
|
36
|
+
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|uiMode"
|
|
37
37
|
android:windowSoftInputMode="adjustResize"
|
|
38
38
|
android:launchMode="singleTask">
|
|
39
39
|
<intent-filter>
|
|
@@ -37,12 +37,16 @@ internal class CraftNativeFlatList(context: Context) : RecyclerView(context) {
|
|
|
37
37
|
override fun getItemId(position: Int): Long = stableId(getItem(position).identity)
|
|
38
38
|
|
|
39
39
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = Holder(FrameLayout(parent.context).apply {
|
|
40
|
-
layoutParams = LayoutParams(
|
|
40
|
+
layoutParams = LayoutParams(
|
|
41
|
+
if (horizontal) LayoutParams.WRAP_CONTENT else LayoutParams.MATCH_PARENT,
|
|
42
|
+
LayoutParams.WRAP_CONTENT,
|
|
43
|
+
)
|
|
41
44
|
})
|
|
42
45
|
|
|
43
46
|
override fun onBindViewHolder(holder: Holder, position: Int) {
|
|
44
47
|
val item = getItem(position)
|
|
45
48
|
if (holder.identity != null && holder.identity != item.identity) release(holder)
|
|
49
|
+
holder.host.layoutParams = holderLayoutParams()
|
|
46
50
|
val view = renderItem?.invoke(item.node, item.identity, holder.hostedView) ?: View(holder.host.context)
|
|
47
51
|
val renderedLayout = view.layoutParams
|
|
48
52
|
val hostedLayout = FrameLayout.LayoutParams(
|
|
@@ -83,19 +87,97 @@ internal class CraftNativeFlatList(context: Context) : RecyclerView(context) {
|
|
|
83
87
|
private var endReached: (() -> Unit)? = null
|
|
84
88
|
private var threshold = 0.1
|
|
85
89
|
private var endReachedSignature: String? = null
|
|
90
|
+
private var dataContentSignature = ""
|
|
86
91
|
private var horizontal = false
|
|
87
92
|
private var columns = 1
|
|
88
93
|
private var submissionGeneration = 0
|
|
89
94
|
private var isSubmittingList = false
|
|
95
|
+
private var pendingThemeRefresh = false
|
|
96
|
+
var onScrollEvent: (() -> Unit)? = null
|
|
97
|
+
var onScrollBeginDrag: (() -> Unit)? = null
|
|
98
|
+
var onScrollEndDrag: (() -> Unit)? = null
|
|
99
|
+
var onMomentumScrollBegin: (() -> Unit)? = null
|
|
100
|
+
var onMomentumScrollEnd: (() -> Unit)? = null
|
|
101
|
+
var onLayoutChanged: (() -> Unit)? = null
|
|
102
|
+
|
|
103
|
+
private fun holderLayoutParams() = LayoutParams(
|
|
104
|
+
if (horizontal) LayoutParams.WRAP_CONTENT else LayoutParams.MATCH_PARENT,
|
|
105
|
+
LayoutParams.WRAP_CONTENT,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
private fun updateVisibleHolderLayoutParams() {
|
|
109
|
+
for (index in 0 until childCount) {
|
|
110
|
+
(getChildViewHolder(getChildAt(index)) as? Holder)?.host?.layoutParams = holderLayoutParams()
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
|
|
115
|
+
super.onLayout(changed, left, top, right, bottom)
|
|
116
|
+
onLayoutChanged?.invoke()
|
|
117
|
+
}
|
|
90
118
|
|
|
91
119
|
init {
|
|
92
120
|
adapter = listAdapter
|
|
93
121
|
itemAnimator = null
|
|
94
122
|
addOnScrollListener(object : OnScrollListener() {
|
|
95
|
-
|
|
123
|
+
private var dragging = false
|
|
124
|
+
private var momentum = false
|
|
125
|
+
|
|
126
|
+
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
|
|
127
|
+
onScrollEvent?.invoke()
|
|
128
|
+
onLayoutChanged?.invoke()
|
|
129
|
+
evaluateEndReached()
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
|
|
133
|
+
when (newState) {
|
|
134
|
+
RecyclerView.SCROLL_STATE_DRAGGING -> if (!dragging) {
|
|
135
|
+
dragging = true
|
|
136
|
+
onScrollBeginDrag?.invoke()
|
|
137
|
+
}
|
|
138
|
+
RecyclerView.SCROLL_STATE_SETTLING -> {
|
|
139
|
+
if (dragging) {
|
|
140
|
+
dragging = false
|
|
141
|
+
onScrollEndDrag?.invoke()
|
|
142
|
+
}
|
|
143
|
+
if (!momentum) {
|
|
144
|
+
momentum = true
|
|
145
|
+
onMomentumScrollBegin?.invoke()
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
RecyclerView.SCROLL_STATE_IDLE -> {
|
|
149
|
+
if (dragging) {
|
|
150
|
+
dragging = false
|
|
151
|
+
onScrollEndDrag?.invoke()
|
|
152
|
+
}
|
|
153
|
+
if (momentum) {
|
|
154
|
+
momentum = false
|
|
155
|
+
onMomentumScrollEnd?.invoke()
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
96
160
|
})
|
|
97
161
|
}
|
|
98
162
|
|
|
163
|
+
fun setKeyboardDismissMode(mode: String, dismissKeyboard: () -> Unit) {
|
|
164
|
+
if (mode !in setOf("on-drag", "interactive")) {
|
|
165
|
+
setOnTouchListener(null)
|
|
166
|
+
return
|
|
167
|
+
}
|
|
168
|
+
var dismissed = false
|
|
169
|
+
setOnTouchListener { _, event ->
|
|
170
|
+
when (event.actionMasked) {
|
|
171
|
+
android.view.MotionEvent.ACTION_DOWN -> dismissed = false
|
|
172
|
+
android.view.MotionEvent.ACTION_MOVE -> if (!dismissed) {
|
|
173
|
+
dismissed = true
|
|
174
|
+
dismissKeyboard()
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
false
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
99
181
|
fun apply(
|
|
100
182
|
nodes: List<JSONObject>,
|
|
101
183
|
horizontal: Boolean,
|
|
@@ -116,7 +198,10 @@ internal class CraftNativeFlatList(context: Context) : RecyclerView(context) {
|
|
|
116
198
|
val retainedState = if (layoutChanged) null else layoutManager?.onSaveInstanceState()
|
|
117
199
|
this.horizontal = horizontal
|
|
118
200
|
this.columns = nextColumns
|
|
119
|
-
if (layoutChanged || layoutManager == null)
|
|
201
|
+
if (layoutChanged || layoutManager == null) {
|
|
202
|
+
configureLayoutManager()
|
|
203
|
+
updateVisibleHolderLayoutParams()
|
|
204
|
+
}
|
|
120
205
|
|
|
121
206
|
val seen = mutableMapOf<String, Int>()
|
|
122
207
|
val next = nodes.mapIndexed { index, node ->
|
|
@@ -132,7 +217,10 @@ internal class CraftNativeFlatList(context: Context) : RecyclerView(context) {
|
|
|
132
217
|
val nextIds = next.mapTo(mutableSetOf()) { it.identity }
|
|
133
218
|
(oldIds - nextIds).forEach(recycleItem)
|
|
134
219
|
val dataItems = next.filterNot { it.chrome }
|
|
135
|
-
val contentSignature =
|
|
220
|
+
val contentSignature = dataItems.joinToString("") {
|
|
221
|
+
"${it.identity.length}:${it.identity}${it.signature.length}:${it.signature}"
|
|
222
|
+
}
|
|
223
|
+
dataContentSignature = contentSignature
|
|
136
224
|
if (contentSignature != endReachedSignature) endReachedSignature = null
|
|
137
225
|
val generation = ++submissionGeneration
|
|
138
226
|
isSubmittingList = true
|
|
@@ -140,6 +228,10 @@ internal class CraftNativeFlatList(context: Context) : RecyclerView(context) {
|
|
|
140
228
|
if (generation != submissionGeneration) return@submitList
|
|
141
229
|
retainedState?.let { layoutManager?.onRestoreInstanceState(it) }
|
|
142
230
|
isSubmittingList = false
|
|
231
|
+
if (pendingThemeRefresh) {
|
|
232
|
+
pendingThemeRefresh = false
|
|
233
|
+
refreshThemeDefaults()
|
|
234
|
+
}
|
|
143
235
|
evaluateEndReached()
|
|
144
236
|
}
|
|
145
237
|
}
|
|
@@ -148,12 +240,28 @@ internal class CraftNativeFlatList(context: Context) : RecyclerView(context) {
|
|
|
148
240
|
listAdapter.currentList.forEach { recycleItem?.invoke(it.identity) }
|
|
149
241
|
val generation = ++submissionGeneration
|
|
150
242
|
isSubmittingList = true
|
|
243
|
+
pendingThemeRefresh = false
|
|
151
244
|
listAdapter.submitList(emptyList()) {
|
|
152
245
|
if (generation == submissionGeneration) isSubmittingList = false
|
|
153
246
|
}
|
|
154
247
|
renderItem = null
|
|
155
248
|
recycleItem = null
|
|
156
249
|
endReached = null
|
|
250
|
+
dataContentSignature = ""
|
|
251
|
+
onScrollEvent = null
|
|
252
|
+
onScrollBeginDrag = null
|
|
253
|
+
onScrollEndDrag = null
|
|
254
|
+
onMomentumScrollBegin = null
|
|
255
|
+
onMomentumScrollEnd = null
|
|
256
|
+
onLayoutChanged = null
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
fun refreshThemeDefaults() {
|
|
260
|
+
if (isSubmittingList) {
|
|
261
|
+
pendingThemeRefresh = true
|
|
262
|
+
return
|
|
263
|
+
}
|
|
264
|
+
if (listAdapter.itemCount > 0) listAdapter.notifyItemRangeChanged(0, listAdapter.itemCount)
|
|
157
265
|
}
|
|
158
266
|
|
|
159
267
|
private fun configureLayoutManager() {
|
|
@@ -179,8 +287,7 @@ internal class CraftNativeFlatList(context: Context) : RecyclerView(context) {
|
|
|
179
287
|
val remaining = dataPositions.size - lastDataOrdinal - 1
|
|
180
288
|
val thresholdItems = maxOf(1, ceil(dataPositions.size * threshold).toInt())
|
|
181
289
|
if (remaining > thresholdItems) return
|
|
182
|
-
val
|
|
183
|
-
val signature = "${dataPositions.size}:$lastIdentity"
|
|
290
|
+
val signature = dataContentSignature
|
|
184
291
|
if (endReachedSignature == signature) return
|
|
185
292
|
endReachedSignature = signature
|
|
186
293
|
endReached?.invoke()
|
|
@@ -17,6 +17,7 @@ import android.webkit.WebSettings
|
|
|
17
17
|
import android.webkit.WebView
|
|
18
18
|
import android.webkit.WebViewClient
|
|
19
19
|
import androidx.appcompat.app.AppCompatActivity
|
|
20
|
+
import androidx.appcompat.app.AppCompatDelegate
|
|
20
21
|
import androidx.core.view.WindowCompat
|
|
21
22
|
import androidx.webkit.WebViewAssetLoader
|
|
22
23
|
import org.json.JSONObject
|
|
@@ -75,6 +76,7 @@ class MainActivity : AppCompatActivity() {
|
|
|
75
76
|
|
|
76
77
|
@SuppressLint("SetJavaScriptEnabled")
|
|
77
78
|
override fun onCreate(savedInstanceState: Bundle?) {
|
|
79
|
+
applyConfiguredAppearance()
|
|
78
80
|
super.onCreate(savedInstanceState)
|
|
79
81
|
|
|
80
82
|
// Enable edge-to-edge
|
|
@@ -111,6 +113,21 @@ class MainActivity : AppCompatActivity() {
|
|
|
111
113
|
setupNetworkMonitoring()
|
|
112
114
|
}
|
|
113
115
|
|
|
116
|
+
private fun applyConfiguredAppearance() {
|
|
117
|
+
val configured = configJson()
|
|
118
|
+
val mode = when (configured?.optString("appearance")) {
|
|
119
|
+
"light" -> AppCompatDelegate.MODE_NIGHT_NO
|
|
120
|
+
"dark" -> AppCompatDelegate.MODE_NIGHT_YES
|
|
121
|
+
"system" -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
|
|
122
|
+
else -> if (configured?.optBoolean("darkMode", true) == false) {
|
|
123
|
+
AppCompatDelegate.MODE_NIGHT_NO
|
|
124
|
+
} else {
|
|
125
|
+
AppCompatDelegate.MODE_NIGHT_YES
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
AppCompatDelegate.setDefaultNightMode(mode)
|
|
129
|
+
}
|
|
130
|
+
|
|
114
131
|
@SuppressLint("SetJavaScriptEnabled")
|
|
115
132
|
private fun setupWebView() {
|
|
116
133
|
webView.settings.apply {
|