craft-native 0.0.123 → 0.0.125
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 +22 -3
- package/dist/android/templates/AndroidManifest.xml.template +1 -1
- package/dist/android/templates/CraftNativeFlatList.kt.template +151 -7
- package/dist/android/templates/CraftNativeRefreshableFlatList.kt.template +29 -0
- package/dist/android/templates/MainActivity.kt.template +17 -0
- package/dist/android/templates/MainActivityNative.kt.template +1085 -84
- package/dist/android/templates/build.gradle.kts.app.template +1 -0
- package/dist/api/mobile.d.ts +250 -2
- package/dist/cli.js +3 -3
- package/dist/index.cjs +22 -1
- package/dist/index.js +22 -1
- package/dist/ios/src/index.d.ts +125 -3
- package/dist/ios/src/index.js +284 -47
- package/dist/ios/templates/CraftApp.swift +1186 -69
- package/dist/ios/templates/CraftHybrid.swift +1196 -0
- package/dist/ios/templates/CraftNativeActions.swift +310 -10
- package/dist/ios/templates/CraftNativeFlatList.swift +226 -17
- package/dist/ios/templates/CraftNativeScreen.swift +2217 -220
- package/dist/ios/templates/CraftNativeUI.swift +562 -0
- package/dist/ios/templates/Info.plist.template +2 -1
- package/dist/mobile.js +204 -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")
|
|
@@ -418,10 +425,12 @@ async function init(options) {
|
|
|
418
425
|
if (config.renderer === "native") {
|
|
419
426
|
const mutationTemplate = readFileSync(join(TEMPLATES_DIR, "CraftNativeMutation.kt.template"), "utf-8");
|
|
420
427
|
const flatListTemplate = readFileSync(join(TEMPLATES_DIR, "CraftNativeFlatList.kt.template"), "utf-8");
|
|
428
|
+
const refreshableFlatListTemplate = readFileSync(join(TEMPLATES_DIR, "CraftNativeRefreshableFlatList.kt.template"), "utf-8");
|
|
421
429
|
const capabilityTemplate = readFileSync(join(TEMPLATES_DIR, "CraftNativeCapabilities.kt.template"), "utf-8");
|
|
422
430
|
const notificationTemplate = readFileSync(join(TEMPLATES_DIR, "CraftNativeNotificationReceiver.kt.template"), "utf-8");
|
|
423
431
|
writeFileSync(join(output, "app/src/main/java", packagePath, "CraftNativeMutation.kt"), mutationTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
|
|
424
432
|
writeFileSync(join(output, "app/src/main/java", packagePath, "CraftNativeFlatList.kt"), flatListTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
|
|
433
|
+
writeFileSync(join(output, "app/src/main/java", packagePath, "CraftNativeRefreshableFlatList.kt"), refreshableFlatListTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
|
|
425
434
|
writeFileSync(join(output, "app/src/main/java", packagePath, "CraftNativeCapabilities.kt"), capabilityTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
|
|
426
435
|
writeFileSync(join(output, "app/src/main/java", packagePath, "CraftNativeNotificationReceiver.kt"), notificationTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
|
|
427
436
|
}
|
|
@@ -468,7 +477,7 @@ async function init(options) {
|
|
|
468
477
|
const projectGradleTemplate = readFileSync(join(TEMPLATES_DIR, "build.gradle.kts.project.template"), "utf-8");
|
|
469
478
|
writeFileSync(join(output, "build.gradle.kts"), projectGradleTemplate.replace(/\{\{GOOGLE_SERVICES_PLUGIN\}\}/g, hasGoogleServices ? ' id("com.google.gms.google-services") version "4.4.2" apply false' : ""));
|
|
470
479
|
const appGradleTemplate = readFileSync(join(TEMPLATES_DIR, "build.gradle.kts.app.template"), "utf-8");
|
|
471
|
-
const appGradle = appGradleTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName).replace(/\{\{VERSION_NAME\}\}/g, escapeKotlinString(config.version || "1.0.0")).replace(/\{\{VERSION_CODE\}\}/g, String(config.versionCode || 1)).replace(/\{\{MIN_SDK\}\}/g, String(config.minSdk ?? ANDROID_MIN_SDK)).replace(/\{\{COMPILE_SDK\}\}/g, String(config.compileSdk || 36)).replace(/\{\{TARGET_SDK\}\}/g, String(config.targetSdk || 35)).replace(/\{\{GOOGLE_SERVICES_PLUGIN\}\}/g, hasGoogleServices ? ' id("com.google.gms.google-services")' : "").replace(/\{\{FIREBASE_MESSAGING_DEPENDENCY\}\}/g, config.enablePushNotifications ? ' implementation("com.google.firebase:firebase-messaging:24.1.0")' : "").replace(/\{\{HEALTH_CONNECT_DEPENDENCIES\}\}/g, config.enableHealthConnect ? ` implementation("androidx.health.connect:connect-client:1.1.0")
|
|
480
|
+
const appGradle = appGradleTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName).replace(/\{\{VERSION_NAME\}\}/g, escapeKotlinString(config.version || "1.0.0")).replace(/\{\{VERSION_CODE\}\}/g, String(config.versionCode || 1)).replace(/\{\{MIN_SDK\}\}/g, String(config.minSdk ?? ANDROID_MIN_SDK)).replace(/\{\{COMPILE_SDK\}\}/g, String(config.compileSdk || 36)).replace(/\{\{TARGET_SDK\}\}/g, String(config.targetSdk || 35)).replace(/\{\{GOOGLE_SERVICES_PLUGIN\}\}/g, hasGoogleServices ? ' id("com.google.gms.google-services")' : "").replace(/\{\{FIREBASE_MESSAGING_DEPENDENCY\}\}/g, config.enablePushNotifications ? ' implementation("com.google.firebase:firebase-messaging:24.1.0")' : "").replace(/\{\{REFRESH_LAYOUT_DEPENDENCY\}\}/g, config.renderer === "native" ? ' implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")' : "").replace(/\{\{HEALTH_CONNECT_DEPENDENCIES\}\}/g, config.enableHealthConnect ? ` implementation("androidx.health.connect:connect-client:1.1.0")
|
|
472
481
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")` : "").replace(/\{\{JAVASCRIPT_ENGINE_DEPENDENCY\}\}/g, config.renderer === "native" ? ' implementation("androidx.javascriptengine:javascriptengine:1.1.0")' : "");
|
|
473
482
|
writeFileSync(join(output, "app/build.gradle.kts"), appGradle);
|
|
474
483
|
const proguardTemplate = readFileSync(join(TEMPLATES_DIR, "proguard-rules.pro.template"), "utf-8");
|
|
@@ -508,6 +517,16 @@ zipStorePath=wrapper/dists
|
|
|
508
517
|
</resources>
|
|
509
518
|
`;
|
|
510
519
|
writeFileSync(join(output, "app/src/main/res/values/colors.xml"), colorsXml);
|
|
520
|
+
const darkColorsPath = join(output, "app/src/main/res/values-night/colors.xml");
|
|
521
|
+
if (config.backgroundColorDark) {
|
|
522
|
+
writeFileSync(darkColorsPath, `<?xml version="1.0" encoding="utf-8"?>
|
|
523
|
+
<resources>
|
|
524
|
+
<color name="background">${config.backgroundColorDark}</color>
|
|
525
|
+
</resources>
|
|
526
|
+
`);
|
|
527
|
+
} else {
|
|
528
|
+
rmSync(darkColorsPath, { force: true });
|
|
529
|
+
}
|
|
511
530
|
const appIconXml = `<?xml version="1.0" encoding="utf-8"?>
|
|
512
531
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
513
532
|
android:width="108dp"
|
|
@@ -601,7 +620,7 @@ zipStorePath=wrapper/dists
|
|
|
601
620
|
console.log("Next steps:");
|
|
602
621
|
console.log(` 1. cd ${output}`);
|
|
603
622
|
if (config.renderer === "native") {
|
|
604
|
-
console.log(" 2. Compile your screen: stx
|
|
623
|
+
console.log(" 2. Compile your screen: stx native compile Screen.stx --format bundle --output screen.js");
|
|
605
624
|
console.log(" 3. Run: craft android build --native-bundle screen.js");
|
|
606
625
|
} else {
|
|
607
626
|
console.log(" 2. Add your web content to app/src/main/assets/index.html");
|
|
@@ -627,7 +646,7 @@ async function build(options) {
|
|
|
627
646
|
if (nativeBundlePath && resolve(nativeBundlePath) !== resolve(destination))
|
|
628
647
|
cpSync(nativeBundlePath, destination);
|
|
629
648
|
if (!existsSync(destination)) {
|
|
630
|
-
throw new Error("Native Android mode needs app/src/main/assets/native-screen.js. Compile a .stx screen with stx
|
|
649
|
+
throw new Error("Native Android mode needs app/src/main/assets/native-screen.js. Compile a .stx screen with `stx native compile` first.");
|
|
631
650
|
}
|
|
632
651
|
} else if (nativeBundlePath) {
|
|
633
652
|
throw new Error('--native-bundle requires renderer: "native" in craft.config.json.');
|
|
@@ -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>
|
|
@@ -14,7 +14,7 @@ import kotlin.math.ceil
|
|
|
14
14
|
|
|
15
15
|
internal typealias CraftNativeRenderItem = (node: JSONObject, identity: String, previous: View?) -> View
|
|
16
16
|
|
|
17
|
-
/** A stable-key RecyclerView host for stx
|
|
17
|
+
/** A stable-key RecyclerView host for stx native FlatList nodes. */
|
|
18
18
|
internal class CraftNativeFlatList(context: Context) : RecyclerView(context) {
|
|
19
19
|
private data class Item(
|
|
20
20
|
val identity: String,
|
|
@@ -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,134 @@ 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
|
|
93
|
+
private var keyboardShouldPersistTaps = "never"
|
|
88
94
|
private var submissionGeneration = 0
|
|
89
95
|
private var isSubmittingList = false
|
|
96
|
+
private var pendingThemeRefresh = false
|
|
97
|
+
var onScrollEvent: (() -> Unit)? = null
|
|
98
|
+
var onScrollBeginDrag: (() -> Unit)? = null
|
|
99
|
+
var onScrollEndDrag: (() -> Unit)? = null
|
|
100
|
+
var onMomentumScrollBegin: (() -> Unit)? = null
|
|
101
|
+
var onMomentumScrollEnd: (() -> Unit)? = null
|
|
102
|
+
var onLayoutChanged: (() -> Unit)? = null
|
|
103
|
+
|
|
104
|
+
private fun holderLayoutParams() = LayoutParams(
|
|
105
|
+
if (horizontal) LayoutParams.WRAP_CONTENT else LayoutParams.MATCH_PARENT,
|
|
106
|
+
LayoutParams.WRAP_CONTENT,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
private fun updateVisibleHolderLayoutParams() {
|
|
110
|
+
for (index in 0 until childCount) {
|
|
111
|
+
(getChildViewHolder(getChildAt(index)) as? Holder)?.host?.layoutParams = holderLayoutParams()
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
|
|
116
|
+
super.onLayout(changed, left, top, right, bottom)
|
|
117
|
+
onLayoutChanged?.invoke()
|
|
118
|
+
}
|
|
90
119
|
|
|
91
120
|
init {
|
|
92
121
|
adapter = listAdapter
|
|
93
122
|
itemAnimator = null
|
|
94
123
|
addOnScrollListener(object : OnScrollListener() {
|
|
95
|
-
|
|
124
|
+
private var dragging = false
|
|
125
|
+
private var momentum = false
|
|
126
|
+
|
|
127
|
+
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
|
|
128
|
+
onScrollEvent?.invoke()
|
|
129
|
+
onLayoutChanged?.invoke()
|
|
130
|
+
evaluateEndReached()
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
|
|
134
|
+
when (newState) {
|
|
135
|
+
RecyclerView.SCROLL_STATE_DRAGGING -> if (!dragging) {
|
|
136
|
+
dragging = true
|
|
137
|
+
onScrollBeginDrag?.invoke()
|
|
138
|
+
}
|
|
139
|
+
RecyclerView.SCROLL_STATE_SETTLING -> {
|
|
140
|
+
if (dragging) {
|
|
141
|
+
dragging = false
|
|
142
|
+
onScrollEndDrag?.invoke()
|
|
143
|
+
}
|
|
144
|
+
if (!momentum) {
|
|
145
|
+
momentum = true
|
|
146
|
+
onMomentumScrollBegin?.invoke()
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
RecyclerView.SCROLL_STATE_IDLE -> {
|
|
150
|
+
if (dragging) {
|
|
151
|
+
dragging = false
|
|
152
|
+
onScrollEndDrag?.invoke()
|
|
153
|
+
}
|
|
154
|
+
if (momentum) {
|
|
155
|
+
momentum = false
|
|
156
|
+
onMomentumScrollEnd?.invoke()
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
96
161
|
})
|
|
97
162
|
}
|
|
98
163
|
|
|
164
|
+
fun setKeyboardDismissMode(mode: String, dismissKeyboard: () -> Unit) {
|
|
165
|
+
if (mode !in setOf("on-drag", "interactive") && keyboardShouldPersistTaps == "always") {
|
|
166
|
+
setOnTouchListener(null)
|
|
167
|
+
return
|
|
168
|
+
}
|
|
169
|
+
var dismissed = false
|
|
170
|
+
setOnTouchListener { _, event ->
|
|
171
|
+
when (event.actionMasked) {
|
|
172
|
+
android.view.MotionEvent.ACTION_DOWN -> dismissed = false
|
|
173
|
+
android.view.MotionEvent.ACTION_MOVE -> if (!dismissed) {
|
|
174
|
+
dismissed = true
|
|
175
|
+
dismissKeyboard()
|
|
176
|
+
}
|
|
177
|
+
android.view.MotionEvent.ACTION_UP -> if (shouldDismissKeyboard(event)) dismissKeyboard()
|
|
178
|
+
}
|
|
179
|
+
false
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
fun setKeyboardShouldPersistTaps(value: String?) {
|
|
184
|
+
keyboardShouldPersistTaps = value?.takeIf { it in setOf("always", "handled", "never") } ?: "never"
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private fun tapTarget(view: View, x: Float, y: Float): View? {
|
|
188
|
+
if (x < 0 || y < 0 || x >= view.width || y >= view.height || view.visibility != View.VISIBLE) return null
|
|
189
|
+
if (view is ViewGroup) {
|
|
190
|
+
for (index in view.childCount - 1 downTo 0) {
|
|
191
|
+
val child = view.getChildAt(index)
|
|
192
|
+
tapTarget(child, x - child.left, y - child.top)?.let { return it }
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return view
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private fun shouldDismissKeyboard(event: android.view.MotionEvent): Boolean {
|
|
199
|
+
if (keyboardShouldPersistTaps == "always") return false
|
|
200
|
+
if (keyboardShouldPersistTaps == "never") return true
|
|
201
|
+
return tapTarget(this, event.x, event.y)?.isClickable != true
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
fun setContentContainerStyle(style: JSONObject?, density: Float) {
|
|
205
|
+
val padding = (style?.optDouble("padding", 0.0) ?: 0.0) * density
|
|
206
|
+
val horizontal = (style?.optDouble("paddingHorizontal", padding / density) ?: (padding / density)) * density
|
|
207
|
+
val vertical = (style?.optDouble("paddingVertical", padding / density) ?: (padding / density)) * density
|
|
208
|
+
setPadding(
|
|
209
|
+
((style?.optDouble("paddingLeft", horizontal / density) ?: (horizontal / density)) * density).toInt(),
|
|
210
|
+
((style?.optDouble("paddingTop", vertical / density) ?: (vertical / density)) * density).toInt(),
|
|
211
|
+
((style?.optDouble("paddingRight", horizontal / density) ?: (horizontal / density)) * density).toInt(),
|
|
212
|
+
((style?.optDouble("paddingBottom", vertical / density) ?: (vertical / density)) * density).toInt(),
|
|
213
|
+
)
|
|
214
|
+
clipToPadding = true
|
|
215
|
+
requestLayout()
|
|
216
|
+
}
|
|
217
|
+
|
|
99
218
|
fun apply(
|
|
100
219
|
nodes: List<JSONObject>,
|
|
101
220
|
horizontal: Boolean,
|
|
@@ -116,7 +235,10 @@ internal class CraftNativeFlatList(context: Context) : RecyclerView(context) {
|
|
|
116
235
|
val retainedState = if (layoutChanged) null else layoutManager?.onSaveInstanceState()
|
|
117
236
|
this.horizontal = horizontal
|
|
118
237
|
this.columns = nextColumns
|
|
119
|
-
if (layoutChanged || layoutManager == null)
|
|
238
|
+
if (layoutChanged || layoutManager == null) {
|
|
239
|
+
configureLayoutManager()
|
|
240
|
+
updateVisibleHolderLayoutParams()
|
|
241
|
+
}
|
|
120
242
|
|
|
121
243
|
val seen = mutableMapOf<String, Int>()
|
|
122
244
|
val next = nodes.mapIndexed { index, node ->
|
|
@@ -132,7 +254,10 @@ internal class CraftNativeFlatList(context: Context) : RecyclerView(context) {
|
|
|
132
254
|
val nextIds = next.mapTo(mutableSetOf()) { it.identity }
|
|
133
255
|
(oldIds - nextIds).forEach(recycleItem)
|
|
134
256
|
val dataItems = next.filterNot { it.chrome }
|
|
135
|
-
val contentSignature =
|
|
257
|
+
val contentSignature = dataItems.joinToString("") {
|
|
258
|
+
"${it.identity.length}:${it.identity}${it.signature.length}:${it.signature}"
|
|
259
|
+
}
|
|
260
|
+
dataContentSignature = contentSignature
|
|
136
261
|
if (contentSignature != endReachedSignature) endReachedSignature = null
|
|
137
262
|
val generation = ++submissionGeneration
|
|
138
263
|
isSubmittingList = true
|
|
@@ -140,6 +265,10 @@ internal class CraftNativeFlatList(context: Context) : RecyclerView(context) {
|
|
|
140
265
|
if (generation != submissionGeneration) return@submitList
|
|
141
266
|
retainedState?.let { layoutManager?.onRestoreInstanceState(it) }
|
|
142
267
|
isSubmittingList = false
|
|
268
|
+
if (pendingThemeRefresh) {
|
|
269
|
+
pendingThemeRefresh = false
|
|
270
|
+
refreshThemeDefaults()
|
|
271
|
+
}
|
|
143
272
|
evaluateEndReached()
|
|
144
273
|
}
|
|
145
274
|
}
|
|
@@ -148,12 +277,28 @@ internal class CraftNativeFlatList(context: Context) : RecyclerView(context) {
|
|
|
148
277
|
listAdapter.currentList.forEach { recycleItem?.invoke(it.identity) }
|
|
149
278
|
val generation = ++submissionGeneration
|
|
150
279
|
isSubmittingList = true
|
|
280
|
+
pendingThemeRefresh = false
|
|
151
281
|
listAdapter.submitList(emptyList()) {
|
|
152
282
|
if (generation == submissionGeneration) isSubmittingList = false
|
|
153
283
|
}
|
|
154
284
|
renderItem = null
|
|
155
285
|
recycleItem = null
|
|
156
286
|
endReached = null
|
|
287
|
+
dataContentSignature = ""
|
|
288
|
+
onScrollEvent = null
|
|
289
|
+
onScrollBeginDrag = null
|
|
290
|
+
onScrollEndDrag = null
|
|
291
|
+
onMomentumScrollBegin = null
|
|
292
|
+
onMomentumScrollEnd = null
|
|
293
|
+
onLayoutChanged = null
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
fun refreshThemeDefaults() {
|
|
297
|
+
if (isSubmittingList) {
|
|
298
|
+
pendingThemeRefresh = true
|
|
299
|
+
return
|
|
300
|
+
}
|
|
301
|
+
if (listAdapter.itemCount > 0) listAdapter.notifyItemRangeChanged(0, listAdapter.itemCount)
|
|
157
302
|
}
|
|
158
303
|
|
|
159
304
|
private fun configureLayoutManager() {
|
|
@@ -179,8 +324,7 @@ internal class CraftNativeFlatList(context: Context) : RecyclerView(context) {
|
|
|
179
324
|
val remaining = dataPositions.size - lastDataOrdinal - 1
|
|
180
325
|
val thresholdItems = maxOf(1, ceil(dataPositions.size * threshold).toInt())
|
|
181
326
|
if (remaining > thresholdItems) return
|
|
182
|
-
val
|
|
183
|
-
val signature = "${dataPositions.size}:$lastIdentity"
|
|
327
|
+
val signature = dataContentSignature
|
|
184
328
|
if (endReachedSignature == signature) return
|
|
185
329
|
endReachedSignature = signature
|
|
186
330
|
endReached?.invoke()
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
package {{PACKAGE_NAME}}
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
|
5
|
+
|
|
6
|
+
/** Swipe-to-refresh host for a native FlatList. */
|
|
7
|
+
internal class CraftNativeRefreshableFlatList(context: Context) : SwipeRefreshLayout(context) {
|
|
8
|
+
val list = CraftNativeFlatList(context)
|
|
9
|
+
private var refreshHandler: (() -> Unit)? = null
|
|
10
|
+
|
|
11
|
+
init {
|
|
12
|
+
addView(list, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
|
|
13
|
+
setOnRefreshListener { refreshHandler?.invoke() }
|
|
14
|
+
isEnabled = false
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
fun configureRefresh(handler: (() -> Unit)?, refreshing: Boolean) {
|
|
18
|
+
refreshHandler = handler
|
|
19
|
+
isEnabled = handler != null
|
|
20
|
+
isRefreshing = handler != null && refreshing
|
|
21
|
+
if (handler == null) isRefreshing = false
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
override fun onDetachedFromWindow() {
|
|
25
|
+
refreshHandler = null
|
|
26
|
+
isRefreshing = false
|
|
27
|
+
super.onDetachedFromWindow()
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -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 {
|