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 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(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
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
- override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) = evaluateEndReached()
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) configureLayoutManager()
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 = "${dataItems.size}:${dataItems.lastOrNull()?.identity.orEmpty()}"
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 lastIdentity = listAdapter.currentList[dataPositions.last()].identity
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 {